test_tui_state.py 32 KB

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