test_tui_state.py 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106
  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. TIME_STYLE,
  8. ViewState,
  9. _fmt_clock,
  10. )
  11. def test_mode_event_updates_mode_and_style():
  12. state = ViewState()
  13. state.ingest({"type": "mode", "mode": "command_armed"})
  14. assert state.mode == "command_armed"
  15. assert state.mode_style() == "yellow"
  16. def test_initial_health_is_not_optimistic():
  17. """Before the first heartbeat arrives, health must read as unknown/down."""
  18. state = ViewState()
  19. assert state.health_ok() is False
  20. state.ingest({"type": "heartbeat", "stt_ok": True, "device_ok": True})
  21. assert state.health_ok() is True
  22. def test_heartbeat_self_corrects_missed_mode():
  23. state = ViewState()
  24. state.ingest(
  25. {
  26. "type": "heartbeat",
  27. "mode": "chat",
  28. "stt_ok": True,
  29. "in_flight": False,
  30. "worker_stall_s": 0.0,
  31. "queue_depth": 2,
  32. "device_ok": True,
  33. }
  34. )
  35. assert state.mode == "chat"
  36. assert state.queue_depth == 2
  37. def test_heartbeat_ingests_inference_depth_and_in_flight():
  38. state = ViewState()
  39. state.ingest(
  40. {
  41. "type": "heartbeat",
  42. "inference_depth": 3,
  43. "inference_in_flight": True,
  44. }
  45. )
  46. assert state.inference_depth == 3
  47. assert state.inference_in_flight is True
  48. def test_heartbeat_omitting_inference_fields_preserves_prior_values():
  49. state = ViewState()
  50. state.inference_depth = 5
  51. state.inference_in_flight = True
  52. state.ingest(
  53. {
  54. "type": "heartbeat",
  55. "mode": "idle",
  56. "stt_ok": True,
  57. }
  58. )
  59. assert state.inference_depth == 5
  60. assert state.inference_in_flight is True
  61. def test_health_red_when_stt_not_ok():
  62. state = ViewState()
  63. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": False})
  64. assert state.health_ok() is False
  65. def test_health_red_when_device_down():
  66. state = ViewState()
  67. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True, "device_ok": False})
  68. assert state.health_ok() is False
  69. def test_worker_stall_recorded_and_degrades():
  70. state = ViewState()
  71. state.ingest({"type": "heartbeat", "stt_ok": False, "worker_stall_s": 16.5})
  72. assert state.worker_stall_s == 16.5
  73. assert state.health_ok() is False
  74. def test_command_text_and_edited_flag():
  75. state = ViewState()
  76. state.ingest({"type": "command_text", "text": "hello", "edited": True, "phase": "ready"})
  77. assert state.command_text == "hello"
  78. assert state.command_edited is True
  79. assert state.phase == "ready"
  80. def test_status_populates_fields():
  81. state = ViewState()
  82. state.ingest(
  83. {
  84. "type": "status",
  85. "mode": "command_armed",
  86. "ready": True,
  87. "phase": "ready",
  88. "command_text": "x",
  89. "stt_ok": True,
  90. }
  91. )
  92. assert state.mode == "command_armed"
  93. assert state.ready is True
  94. assert state.command_text == "x"
  95. def test_chat_capture_defaults_off_and_follows_event_and_status():
  96. state = ViewState()
  97. assert state.chat_capture is False
  98. state.ingest({"type": "chat_capture", "on": True})
  99. assert state.chat_capture is True
  100. state.ingest({"type": "status", "chat_capture": False})
  101. assert state.chat_capture is False
  102. def test_triage_chat_defaults_off_and_follows_event_and_status():
  103. state = ViewState()
  104. assert state.triage_chat is False
  105. state.ingest({"type": "triage_chat", "on": True})
  106. assert state.triage_chat is True
  107. state.ingest({"type": "status", "triage_chat": False})
  108. assert state.triage_chat is False
  109. def test_transcripts_are_bounded():
  110. state = ViewState()
  111. for i in range(MAX_TRANSCRIPTS + 25):
  112. state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  113. assert len(state.transcripts) == MAX_TRANSCRIPTS
  114. assert state.transcripts[-1]["text"] == f"t{MAX_TRANSCRIPTS + 24}"
  115. last_block = state.transcript_lines()[-1].plain
  116. assert f"t{MAX_TRANSCRIPTS + 24}" in last_block
  117. assert "EN" in last_block
  118. def test_transcript_block_header_has_clock_lang_duration_and_reason():
  119. state = ViewState()
  120. state.ingest(
  121. {
  122. "type": "transcript",
  123. "ts": 1759073916.19,
  124. "lang": "es",
  125. "lang_prob": 0.99,
  126. "duration_s": 7.83,
  127. "end_reason": "end_of_turn",
  128. "text": "hola mundo",
  129. }
  130. )
  131. header, body = state.transcript_lines()[0].plain.split("\n")
  132. assert header[2] == ":" and header[5] == ":" and len(header[:8]) == 8
  133. assert "ES" in header
  134. assert "7.8s" in header
  135. assert "end_of_turn" in header
  136. assert body.strip() == "hola mundo"
  137. def _header_span_style(block, fragment: str) -> str:
  138. for span in block.spans:
  139. if block.plain[span.start : span.end] == fragment:
  140. return str(span.style)
  141. raise AssertionError(f"no span rendering {fragment!r} in {block.plain!r}")
  142. def test_transcript_header_places_named_speaker_between_lang_and_duration():
  143. state = ViewState()
  144. state.ingest(
  145. {
  146. "type": "transcript",
  147. "ts": 1759073916.19,
  148. "lang": "en",
  149. "duration_s": 3.24,
  150. "end_reason": "pause",
  151. "speaker": "Efren",
  152. "speaker_id": "spk_0001",
  153. "speaker_status": "matched",
  154. "speaker_score": 0.87,
  155. "text": "turn on the lights",
  156. }
  157. )
  158. block = state.transcript_lines()[0]
  159. header, body = block.plain.split("\n")
  160. assert header.index("EN") < header.index("Efren") < header.index("3.2s")
  161. assert _header_span_style(block, "Efren") == SPEAKER_NAMED_STYLE == "bold green"
  162. assert body.strip() == "turn on the lights"
  163. def test_transcript_header_renders_matched_but_unnamed_speaker_as_unknown():
  164. state = ViewState()
  165. state.ingest(
  166. {
  167. "type": "transcript",
  168. "ts": 1759073916.19,
  169. "lang": "en",
  170. "duration_s": 3.24,
  171. "speaker": "Unknown",
  172. "speaker_id": "spk_0002",
  173. "speaker_status": "matched",
  174. "text": "hello there",
  175. }
  176. )
  177. block = state.transcript_lines()[0]
  178. header = block.plain.split("\n")[0]
  179. assert header.index("EN") < header.index("Unknown") < header.index("3.2s")
  180. assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE == "bold red"
  181. @pytest.mark.parametrize("status", ["uncertain", "unknown", "none"])
  182. def test_transcript_header_renders_unmatched_statuses_as_unknown(status):
  183. state = ViewState()
  184. state.ingest(
  185. {
  186. "type": "transcript",
  187. "ts": 1759073916.19,
  188. "lang": "es",
  189. "duration_s": 1.0,
  190. "speaker": "Unknown",
  191. "speaker_status": status,
  192. "text": "hola",
  193. }
  194. )
  195. block = state.transcript_lines()[0]
  196. assert "Unknown" in block.plain
  197. assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE
  198. def test_transcript_header_without_speaker_field_renders_unknown():
  199. state = ViewState()
  200. state.ingest(
  201. {
  202. "type": "transcript",
  203. "ts": 1759073916.19,
  204. "lang": "en",
  205. "duration_s": 1.0,
  206. "text": "no speaker field",
  207. }
  208. )
  209. block = state.transcript_lines()[0]
  210. assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE
  211. def test_speakers_event_populates_the_speaker_snapshot():
  212. speakers = [
  213. {
  214. "id": "spk_0001",
  215. "name": "Efren",
  216. "status": "confirmed",
  217. "cumulative_s": 42.5,
  218. "sample_path": "speakers/samples/spk_0001.wav",
  219. "created_at": "2026-09-28T12:00:00",
  220. }
  221. ]
  222. state = ViewState()
  223. state.ingest({"type": "speakers", "speakers": speakers})
  224. assert state.speakers == speakers
  225. def test_speakers_event_without_payload_clears_the_snapshot():
  226. state = ViewState()
  227. state.speakers = [{"id": "spk_0001"}]
  228. state.ingest({"type": "speakers"})
  229. assert state.speakers == []
  230. def test_transcript_time_language_and_text_colors_are_disjoint():
  231. state = ViewState()
  232. for lang in ("es", "en", "fr", "de", "pt", "it", "zz"):
  233. state.ingest(
  234. {
  235. "type": "transcript",
  236. "ts": 1.0,
  237. "lang": lang,
  238. "duration_s": 1.0,
  239. "end_reason": "pause",
  240. "text": "x",
  241. }
  242. )
  243. palette = {
  244. str(span.style).split()[-1]
  245. for block in state.transcript_lines()
  246. for span in block.spans
  247. }
  248. time_color = str(state.transcript_lines()[0].spans[0].style).split()[-1]
  249. lang_color = str(state.transcript_lines()[0].spans[1].style).split()[-1]
  250. text_colors = {
  251. str(block.spans[-1].style).split()[-1] for block in state.transcript_lines()
  252. }
  253. assert not ({time_color, lang_color} & text_colors), palette
  254. assert time_color != lang_color
  255. def test_transcripts_total_keeps_counting_past_the_cap():
  256. """The bounded buffer drops old lines; the total must still advance."""
  257. state = ViewState()
  258. for i in range(MAX_TRANSCRIPTS + 25):
  259. state.ingest({"type": "transcript", "text": f"t{i}", "lang": "en"})
  260. assert state.transcripts_total == MAX_TRANSCRIPTS + 25
  261. def test_command_output_is_collected_for_display():
  262. state = ViewState()
  263. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  264. state.ingest(
  265. {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "2026-09-21T01:00:00"}
  266. )
  267. state.ingest({"type": "error", "code": "not_ready", "message": "command is not ready"})
  268. lines = state.lower_lines()
  269. assert any("tool get_datetime" in line for line in lines)
  270. assert any("ok:" in line and "2026-09-21" in line for line in lines)
  271. assert any("not_ready" in line for line in lines)
  272. def test_chat_turns_also_appear_in_lower_log():
  273. state = ViewState()
  274. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  275. assert state.lower_lines() == ["user: hello"]
  276. def test_output_and_chat_selectors_partition_lower_log():
  277. state = ViewState()
  278. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  279. state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  280. state.ingest({"type": "error", "code": "boom", "message": "m"})
  281. assert state.output_lines() == ["-> tool get_datetime", "! boom: m"]
  282. assert state.chat_output_lines() == ["user: hi"]
  283. assert sorted(state.output_lines() + state.chat_output_lines()) == sorted(
  284. state.lower_lines()
  285. )
  286. def test_lower_log_is_bounded():
  287. from stt_daemon.tui.state import MAX_LOWER
  288. state = ViewState()
  289. for i in range(MAX_LOWER + 20):
  290. state.ingest({"type": "error", "code": f"e{i}", "message": "m"})
  291. assert len(state.lower_lines()) == MAX_LOWER
  292. def test_speech_activity_is_tracked():
  293. state = ViewState()
  294. assert state.speech_open is False
  295. state.ingest({"type": "speech_start"})
  296. assert state.speech_open is True
  297. state.ingest({"type": "speech_stop"})
  298. assert state.speech_open is False
  299. state.ingest({"type": "status", "speech_open": True})
  300. assert state.speech_open is True
  301. def test_chat_and_tool_events_recorded():
  302. state = ViewState()
  303. state.ingest({"type": "chat_message", "role": "user", "text": "hi"})
  304. state.ingest({"type": "chat_message", "role": "assistant", "text": "hello"})
  305. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  306. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  307. state.ingest({"type": "error", "code": "no_viewer", "message": "m"})
  308. assert state.chat_lines() == ["user: hi", "assistant: hello"]
  309. assert state.tool is not None and state.tool["tool"] == "get_datetime"
  310. assert state.tool_result is not None and state.tool_result["ok"] is True
  311. assert state.last_error is not None and state.last_error["code"] == "no_viewer"
  312. def test_leaving_chat_keeps_chat_and_command_output():
  313. state = ViewState()
  314. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  315. state.ingest({"type": "mode", "mode": "chat"})
  316. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  317. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  318. assert state.chat_lines() == ["user: hello", "assistant: hi"]
  319. epoch = state.chat_epoch
  320. state.ingest({"type": "mode", "mode": "idle"})
  321. assert state.chat_lines() == ["user: hello", "assistant: hi"]
  322. assert state.lower_lines() == ["-> tool get_datetime", "user: hello", "assistant: hi"]
  323. assert state.chat_epoch == epoch
  324. def test_heartbeat_mode_change_keeps_chat():
  325. state = ViewState()
  326. state.ingest({"type": "mode", "mode": "chat"})
  327. state.ingest({"type": "chat_message", "role": "user", "text": "x"})
  328. state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
  329. assert state.chat_lines() == ["user: x"]
  330. assert state.lower_lines() == ["user: x"]
  331. def test_compaction_note_persists_until_cleared():
  332. state = ViewState()
  333. state.ingest({"type": "mode", "mode": "chat"})
  334. state.ingest(
  335. {
  336. "type": "chat_message",
  337. "role": "note",
  338. "text": "Compacted 4 earlier turns into a summary.",
  339. }
  340. )
  341. assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
  342. state.ingest({"type": "mode", "mode": "idle"})
  343. assert "note: Compacted 4 earlier turns into a summary." in state.lower_lines()
  344. state.ingest({"type": "cleared"})
  345. assert state.lower_lines() == []
  346. def test_entering_chat_does_not_clear_rendered_log():
  347. state = ViewState()
  348. state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
  349. epoch = state.chat_epoch
  350. state.ingest({"type": "mode", "mode": "chat"})
  351. assert state.chat_epoch == epoch
  352. assert any("ok:" in line for line in state.lower_lines())
  353. def test_cleared_event_wipes_output_and_increments_epoch():
  354. state = ViewState()
  355. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  356. state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
  357. state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
  358. state.ingest(
  359. {
  360. "type": "tool_result",
  361. "tool": "get_datetime",
  362. "ok": True,
  363. "result": "2026-09-21T01:00:00",
  364. }
  365. )
  366. state.ingest({"type": "error", "code": "test_error", "message": "test message"})
  367. state.ingest({"type": "transcript", "text": "preserve this", "lang": "en"})
  368. epoch = state.chat_epoch
  369. assert len(state.chat_lines()) == 2
  370. assert len(state.lower_lines()) >= 4
  371. assert state.tool is not None
  372. assert state.tool_result is not None
  373. assert state.last_error is not None
  374. assert len(state.transcripts) == 1
  375. state.ingest({"type": "cleared"})
  376. assert state.chat_lines() == []
  377. assert state.lower_lines() == []
  378. assert state.tool is None
  379. assert state.tool_result is None
  380. assert state.last_error is None
  381. assert state.chat_epoch == epoch + 1
  382. assert len(state.transcripts) == 1
  383. block = state.transcript_lines()[0].plain
  384. assert "preserve this" in block
  385. assert "EN" in block
  386. def _signals_triage():
  387. return {
  388. "order": ["q_choice", "q_noul", "q_score", "q_unknown"],
  389. "items": {
  390. "q_choice": {
  391. "kind": "choice",
  392. "choice": "yes",
  393. "answer_confidence": 0.91,
  394. "flagged": True,
  395. },
  396. "q_noul": {"kind": "noul", "p": 0.75, "flagged": False},
  397. "q_score": {"kind": "score", "level": 3, "label": "high", "flagged": True},
  398. "q_unknown": {"kind": "mystery", "flagged": False},
  399. },
  400. "escalated": True,
  401. "input_tokens": 42,
  402. }
  403. def test_triage_signals_event_stores_and_bumps_epoch():
  404. state = ViewState()
  405. epoch = state.triage_epoch
  406. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  407. assert state.triage == _signals_triage()
  408. assert state.triage_epoch == epoch + 1
  409. def test_triage_brief_event_replaces_triage_and_bumps_epoch():
  410. state = ViewState()
  411. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  412. epoch = state.triage_epoch
  413. merged = {**_signals_triage(), "brief": "one brief line"}
  414. state.ingest({"type": "triage", "stage": "content", "triage": merged})
  415. assert state.triage == merged
  416. assert state.triage_epoch == epoch + 1
  417. def test_triage_cleared_event_empties_and_bumps_epoch():
  418. state = ViewState()
  419. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  420. epoch = state.triage_epoch
  421. state.ingest({"type": "triage", "stage": "cleared"})
  422. assert state.triage is None
  423. assert state.triage_epoch == epoch + 1
  424. def test_status_adopts_triage_fields():
  425. state = ViewState()
  426. epoch = state.triage_epoch
  427. state.ingest(
  428. {
  429. "type": "status",
  430. "triage": _signals_triage(),
  431. "triage_state": "ok",
  432. "triage_reason": "",
  433. }
  434. )
  435. assert state.triage_state == "ok"
  436. assert state.triage == _signals_triage()
  437. assert state.triage_epoch == epoch + 1
  438. def test_status_without_triage_fields_bumps_nothing():
  439. state = ViewState()
  440. state.ingest({"type": "status", "mode": "idle", "ready": True})
  441. assert state.triage_epoch == 0
  442. assert state.triage_state == "disabled"
  443. def test_triage_lines_choice_noul_score_and_flag_marks():
  444. state = ViewState()
  445. state.triage = _signals_triage()
  446. state.triage_state = "ok"
  447. lines = state.triage_lines()
  448. assert lines[0] == "q_choice: yes (0.91) ⚠"
  449. assert lines[1].startswith("███████ q_noul: 0.75")
  450. assert lines[2].startswith("███") and "q_score: high ⚠" in lines[2]
  451. assert lines[3] == "q_unknown"
  452. def test_triage_lines_brief_appended_line_by_line():
  453. state = ViewState()
  454. state.triage = {
  455. "order": ["q1"],
  456. "items": {"q1": {"kind": "choice", "choice": "no", "answer_confidence": 0.9}},
  457. "brief": "First line.\nSecond line.\nThird line.",
  458. }
  459. state.triage_state = "ok"
  460. assert state.triage_lines() == [
  461. "q1: no (0.90)",
  462. "",
  463. "First line.",
  464. "Second line.",
  465. "Third line.",
  466. ]
  467. def test_triage_lines_missing_or_non_str_brief_degrades_to_signals_only():
  468. state = ViewState()
  469. base = {
  470. "order": ["q1"],
  471. "items": {"q1": {"kind": "choice", "choice": "yes", "answer_confidence": 0.5}},
  472. }
  473. state.triage = base
  474. state.triage_state = "ok"
  475. assert state.triage_lines() == ["q1: yes (0.50)"]
  476. for malformed in (None, 123, ["not", "a", "str"], {"brief": "nested"}):
  477. state.triage = {**base, "brief": malformed}
  478. assert state.triage_lines() == ["q1: yes (0.50)"]
  479. def test_triage_lines_brief_none_never_raises():
  480. state = ViewState()
  481. state.triage = {"brief": None}
  482. state.triage_state = "ok"
  483. assert state.triage_lines() == ["triage: no signals"]
  484. def test_triage_lines_empty_brief_renders_nothing_extra():
  485. state = ViewState()
  486. state.triage = {
  487. "order": ["q1"],
  488. "items": {"q1": {"kind": "choice", "choice": "yes", "answer_confidence": 0.5}},
  489. "brief": "",
  490. }
  491. state.triage_state = "ok"
  492. assert state.triage_lines() == ["q1: yes (0.50)"]
  493. def test_triage_lines_non_ok_state_renders_reason():
  494. state = ViewState()
  495. state.triage_state = "loading"
  496. state.triage_reason = "model warming up"
  497. assert state.triage_lines() == ["triage: unavailable — model warming up"]
  498. def test_triage_lines_unknown_reason_falls_back_generically():
  499. state = ViewState()
  500. state.triage_state = "service_down"
  501. assert state.triage_lines() == ["triage: unavailable — service_down"]
  502. def test_triage_lines_ok_without_data_renders_no_data():
  503. state = ViewState()
  504. state.triage_state = "ok"
  505. assert state.triage_lines() == ["triage: unavailable — no data"]
  506. def test_triage_lines_malformed_payload_never_raises():
  507. state = ViewState()
  508. state.triage_state = "ok"
  509. state.triage = {
  510. "order": ["a", "b", "c"],
  511. "items": {
  512. "a": {"kind": "choice", "flagged": True},
  513. "b": {"kind": "noul", "p": "not-a-number", "flagged": True},
  514. "c": {"kind": "score", "level": None},
  515. },
  516. }
  517. lines = state.triage_lines()
  518. assert lines[0] == "a: ? (?) ⚠"
  519. assert "b: ? ⚠" in lines[1]
  520. assert "c: ?" in lines[2]
  521. def test_triage_lines_empty_order_renders_no_signals():
  522. state = ViewState()
  523. state.triage_state = "ok"
  524. state.triage = {"order": [], "items": {}}
  525. assert state.triage_lines() == ["triage: no signals"]
  526. def test_clear_output_resets_triage_and_bumps_epoch():
  527. state = ViewState()
  528. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  529. epoch = state.triage_epoch
  530. state.clear_output()
  531. assert state.triage is None
  532. assert state.triage_epoch == epoch + 1
  533. def test_status_adopted_triage_survives_then_cleared_event_empties_panel():
  534. state = ViewState()
  535. state.ingest(
  536. {"type": "status", "triage": _signals_triage(), "triage_state": "ok", "triage_reason": ""}
  537. )
  538. assert state.triage_lines()[0].startswith("q_choice:")
  539. state.ingest({"type": "triage", "stage": "cleared"})
  540. assert state.triage is None
  541. def test_lone_signals_event_renders_without_status_snapshot():
  542. state = ViewState()
  543. state.ingest(
  544. {
  545. "type": "triage",
  546. "stage": "signals",
  547. "triage": {
  548. "order": ["category"],
  549. "items": {
  550. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  551. },
  552. },
  553. }
  554. )
  555. assert state.triage_state == "ok"
  556. assert state.triage_lines() == ["category: question (0.91)"]
  557. def test_lone_brief_event_renders_without_status_snapshot():
  558. state = ViewState()
  559. state.ingest(
  560. {
  561. "type": "triage",
  562. "stage": "content",
  563. "triage": {
  564. "order": ["category"],
  565. "items": {
  566. "category": {"kind": "choice", "choice": "question", "answer_confidence": 0.91}
  567. },
  568. "brief": "wants action",
  569. },
  570. }
  571. )
  572. assert state.triage_state == "ok"
  573. lines = state.triage_lines()
  574. assert lines[0] == "category: question (0.91)"
  575. assert lines[1] == ""
  576. assert lines[2] == "wants action"
  577. def test_cleared_event_after_lone_signals_leaves_state_usable():
  578. state = ViewState()
  579. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  580. state.ingest({"type": "triage", "stage": "cleared"})
  581. assert state.triage is None
  582. assert state.triage_lines() == ["triage: unavailable — no data"]
  583. def test_triage_event_with_ts_records_last_triage_time():
  584. state = ViewState()
  585. state.ingest(
  586. {"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": 1759073916.19}
  587. )
  588. assert state.triage_ts == 1759073916.19
  589. def test_triage_event_without_ts_keeps_previous_time():
  590. state = ViewState()
  591. state.ingest(
  592. {"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": 1759073916.19}
  593. )
  594. state.ingest(
  595. {
  596. "type": "triage",
  597. "stage": "content",
  598. "triage": {**_signals_triage(), "content": {"summary": "s"}},
  599. }
  600. )
  601. assert state.triage_ts == 1759073916.19
  602. def test_triage_renderables_prepends_clock_in_transcript_time_style():
  603. ts = 1759073916.19
  604. state = ViewState()
  605. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
  606. renderables = state.triage_renderables()
  607. assert renderables[0].plain == _fmt_clock(ts)
  608. assert str(renderables[0].spans[0].style) == TIME_STYLE
  609. assert [item.plain for item in renderables[1:]] == state.triage_lines()
  610. def test_triage_cleared_event_and_clear_output_drop_the_clock():
  611. ts = 1759073916.19
  612. state = ViewState()
  613. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
  614. state.ingest({"type": "triage", "stage": "cleared"})
  615. assert state.triage_ts is None
  616. assert all(item.plain != _fmt_clock(ts) for item in state.triage_renderables())
  617. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
  618. state.clear_output()
  619. assert state.triage_ts is None
  620. assert all(item.plain != _fmt_clock(ts) for item in state.triage_renderables())
  621. def test_triage_renderables_clock_only_for_ok_dict_triage():
  622. ts = 1759073916.19
  623. clock = _fmt_clock(ts)
  624. state = ViewState()
  625. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
  626. state.triage_state = "loading"
  627. state.triage_reason = "warming up"
  628. assert state.triage_renderables()[0].plain == "triage: unavailable — warming up"
  629. assert all(item.plain != clock for item in state.triage_renderables())
  630. state.triage_state = "ok"
  631. state.triage = None
  632. assert state.triage_renderables()[0].plain == "triage: unavailable — no data"
  633. assert all(item.plain != clock for item in state.triage_renderables())
  634. def test_status_adoption_preserves_last_triage_time():
  635. ts = 1759073916.19
  636. state = ViewState()
  637. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage(), "ts": ts})
  638. state.ingest(
  639. {
  640. "type": "status",
  641. "triage": _signals_triage(),
  642. "triage_state": "ok",
  643. "triage_reason": "",
  644. }
  645. )
  646. assert state.triage_ts == ts
  647. assert state.triage_renderables()[0].plain == _fmt_clock(ts)
  648. def _questions():
  649. return {
  650. "topic": {
  651. "type": "choice",
  652. "instructions": "What is the main theme?",
  653. "criteria": {
  654. "planning": "roadmap or prioritization",
  655. "problem": "a blocker or risk",
  656. },
  657. },
  658. "urgency": {
  659. "type": "choice",
  660. "instructions": "How urgent?",
  661. "criteria": ["low", "high"],
  662. },
  663. "sentiment": {
  664. "type": "score",
  665. "instructions": "Overall tone?",
  666. "criteria": ["negative", "neutral", "positive"],
  667. },
  668. "blocker": {
  669. "type": "noul",
  670. "instructions": "A blocker was raised.",
  671. "criteria": {"true": "an obstacle was raised", "false": "no obstacle"},
  672. },
  673. "owner_named": {
  674. "type": "noul",
  675. "instructions": "A responsible person was named.",
  676. },
  677. }
  678. def test_status_adopts_questions_and_bumps_questions_epoch_only():
  679. state = ViewState()
  680. triage_epoch = state.triage_epoch
  681. questions = _questions()
  682. state.ingest({"type": "status", "questions": questions})
  683. assert state.questions == questions
  684. assert state.questions_epoch == 1
  685. assert state.triage_epoch == triage_epoch
  686. def test_status_without_questions_preserves_loaded_questions():
  687. state = ViewState()
  688. questions = _questions()
  689. state.ingest({"type": "status", "questions": questions})
  690. state.ingest({"type": "status", "mode": "idle", "ready": True})
  691. assert state.questions == questions
  692. assert state.questions_epoch == 1
  693. def test_questions_lines_render_choice_dict_choice_list_score_and_noul():
  694. state = ViewState()
  695. state.questions = _questions()
  696. assert state.questions_lines() == [
  697. "topic [choice]",
  698. " What is the main theme?",
  699. " planning — roadmap or prioritization",
  700. " problem — a blocker or risk",
  701. "urgency [choice]",
  702. " How urgent?",
  703. " low",
  704. " high",
  705. "sentiment [score]",
  706. " Overall tone?",
  707. " 1. negative",
  708. " 2. neutral",
  709. " 3. positive",
  710. "blocker [noul]",
  711. " A blocker was raised.",
  712. " true — an obstacle was raised",
  713. " false — no obstacle",
  714. "owner_named [noul]",
  715. " A responsible person was named.",
  716. ]
  717. def test_questions_render_while_triage_lane_is_loading():
  718. state = ViewState()
  719. state.questions = _questions()
  720. state.triage_state = "loading"
  721. state.triage_reason = "warming up"
  722. assert state.questions_lines()[0] == "topic [choice]"
  723. assert not any("unavailable" in line for line in state.questions_lines())
  724. def test_questions_lines_unavailable_when_missing():
  725. state = ViewState()
  726. state.triage_state = "loading"
  727. state.triage_reason = "model warming up"
  728. assert state.questions_lines() == ["questions: unavailable — model warming up"]
  729. def test_questions_lines_unavailable_falls_back_to_state_then_generic():
  730. state = ViewState()
  731. assert state.questions_lines() == ["questions: unavailable — disabled"]
  732. state.triage_state = ""
  733. state.triage_reason = ""
  734. assert state.questions_lines() == ["questions: unavailable — no questions loaded"]
  735. def test_questions_lines_unavailable_for_empty_or_non_dict_payload():
  736. state = ViewState()
  737. state.triage_state = "no_questions"
  738. state.triage_reason = "no questions loaded from questions.json"
  739. for payload in (None, {}, [], "junk", 7):
  740. state.ingest({"type": "status", "questions": payload})
  741. assert state.questions_lines() == [
  742. "questions: unavailable — no questions loaded from questions.json"
  743. ]
  744. def test_questions_lines_malformed_entries_never_raise():
  745. state = ViewState()
  746. state.questions = {
  747. "good": {"type": "choice", "instructions": "pick", "criteria": {"a": "b"}},
  748. "not_a_dict": "boom",
  749. "no_instructions": {"type": "noul"},
  750. "bad_criteria": {"type": "score", "instructions": "level?", "criteria": {}},
  751. "empty_criteria": {"type": "choice", "instructions": "choose", "criteria": []},
  752. "no_type": {"instructions": "huh"},
  753. }
  754. lines = state.questions_lines()
  755. assert lines == [
  756. "good [choice]",
  757. " pick",
  758. " a — b",
  759. "no_instructions [noul]",
  760. "bad_criteria [score]",
  761. " level?",
  762. "empty_criteria [choice]",
  763. " choose",
  764. "no_type [?]",
  765. " huh",
  766. ]
  767. def test_questions_lines_all_malformed_falls_back_to_unavailable():
  768. state = ViewState()
  769. state.triage_state = "ok"
  770. state.questions = {"broken": "not a dict"}
  771. assert state.questions_lines() == ["questions: unavailable — ok"]
  772. def test_questions_renderables_plain_matches_lines_with_styled_header():
  773. state = ViewState()
  774. state.questions = _questions()
  775. renderables = state.questions_renderables()
  776. assert [item.plain for item in renderables] == state.questions_lines()
  777. header = renderables[0]
  778. assert header.plain == "topic [choice]"
  779. assert [(header.plain[s.start : s.end], str(s.style)) for s in header.spans] == [
  780. ("topic", "bold"),
  781. ("[choice]", "dim"),
  782. ]
  783. assert all(not item.spans for item in renderables[1:4])
  784. def test_questions_renderables_unavailable_is_plain_single_line():
  785. state = ViewState()
  786. state.triage_state = "loading"
  787. state.triage_reason = "warming up"
  788. renderables = state.questions_renderables()
  789. assert [item.plain for item in renderables] == [
  790. "questions: unavailable — warming up"
  791. ]
  792. assert not renderables[0].spans
  793. def test_clear_output_keeps_questions():
  794. state = ViewState()
  795. state.questions = _questions()
  796. state.ingest({"type": "triage", "stage": "signals", "triage": _signals_triage()})
  797. state.clear_output()
  798. assert state.questions == _questions()
  799. assert state.questions_epoch == 0
  800. assert state.questions_lines()[0] == "topic [choice]"
  801. def test_status_event_carrying_session_dir_sets_it():
  802. state = ViewState()
  803. assert state.session_dir is None
  804. state.ingest({"type": "status", "session_dir": "transcripts/2026-09-30-12-47-29"})
  805. assert state.session_dir == "transcripts/2026-09-30-12-47-29"
  806. def test_status_without_session_dir_preserves_prior_value():
  807. state = ViewState()
  808. state.ingest({"type": "status", "session_dir": "transcripts/2026-09-30-12-47-29"})
  809. state.ingest({"type": "status", "mode": "idle", "ready": True})
  810. assert state.session_dir == "transcripts/2026-09-30-12-47-29"
  811. def test_session_event_sets_session_dir():
  812. state = ViewState()
  813. state.ingest(
  814. {
  815. "type": "session",
  816. "session_id": "2026-09-30-12-47-29",
  817. "session_dir": "transcripts/2026-09-30-12-47-29",
  818. }
  819. )
  820. assert state.session_dir == "transcripts/2026-09-30-12-47-29"
  821. def test_transcript_carrying_session_dir_sets_it():
  822. state = ViewState()
  823. state.ingest(
  824. {
  825. "type": "transcript",
  826. "text": "hola",
  827. "lang": "es",
  828. "session_dir": "transcripts/2026-09-30-12-47-29",
  829. }
  830. )
  831. assert state.session_dir == "transcripts/2026-09-30-12-47-29"
  832. def test_cleared_keeps_session_dir():
  833. state = ViewState()
  834. state.ingest({"type": "status", "session_dir": "transcripts/2026-09-30-12-47-29"})
  835. state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
  836. state.ingest({"type": "cleared"})
  837. assert state.session_dir == "transcripts/2026-09-30-12-47-29"
  838. assert state.chat_lines() == []
  839. def test_triage_lines_marks_probability_movement_against_previous():
  840. state = ViewState()
  841. state.triage_prev = {
  842. "order": ["q"],
  843. "items": {"q": {"kind": "noul", "p": 0.20, "flagged": False}},
  844. }
  845. state.triage = {
  846. "order": ["q"],
  847. "items": {"q": {"kind": "noul", "p": 0.75, "flagged": True}},
  848. }
  849. state.triage_state = "ok"
  850. line = state.triage_lines()[0]
  851. assert line.endswith("\u2191 \u26a0")
  852. assert "0.75" in line
  853. def test_triage_lines_marks_downward_movement():
  854. state = ViewState()
  855. state.triage_prev = {
  856. "order": ["q"],
  857. "items": {"q": {"kind": "noul", "p": 0.80, "flagged": True}},
  858. }
  859. state.triage = {
  860. "order": ["q"],
  861. "items": {"q": {"kind": "noul", "p": 0.30, "flagged": False}},
  862. }
  863. state.triage_state = "ok"
  864. assert state.triage_lines()[0].endswith("\u2193")
  865. def test_triage_lines_only_warns_on_a_new_flag():
  866. state = ViewState()
  867. prev = {"order": ["q"], "items": {"q": {"kind": "noul", "p": 0.9, "flagged": True}}}
  868. state.triage_prev = prev
  869. state.triage = {
  870. "order": ["q"],
  871. "items": {"q": {"kind": "noul", "p": 0.9, "flagged": True}},
  872. }
  873. state.triage_state = "ok"
  874. assert "\u26a0" not in state.triage_lines()[0]
  875. def test_triage_lines_shows_the_previous_choice_label():
  876. state = ViewState()
  877. state.triage_prev = {
  878. "order": ["q"],
  879. "items": {"q": {"kind": "choice", "choice": "proposed", "answer_confidence": 0.9}},
  880. }
  881. state.triage = {
  882. "order": ["q"],
  883. "items": {"q": {"kind": "choice", "choice": "made", "answer_confidence": 0.9}},
  884. }
  885. state.triage_state = "ok"
  886. assert state.triage_lines()[0] == "q: made (0.90) \u2190 proposed"
  887. def test_triage_lines_reports_hidden_low_signals():
  888. state = ViewState()
  889. state.triage = {
  890. "order": ["q1"],
  891. "items": {"q1": {"kind": "choice", "choice": "yes", "answer_confidence": 0.9}},
  892. "hidden": 2,
  893. }
  894. state.triage_state = "ok"
  895. assert state.triage_lines()[-1] == "(2 low signals hidden)"
  896. state.triage = {**state.triage, "hidden": 1}
  897. assert state.triage_lines()[-1] == "(1 low signal hidden)"