test_tui_state.py 33 KB

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