"""The monitor must record step-by-step turns (heard / thought / answered, per-step timing, ok vs error) and stream them to subscribers — that data is exactly what the status dashboard renders.""" import asyncio import json from wsai.backends.mock import MockBrain, MockSTT, MockTTS from wsai.monitor import Monitor from wsai.pipeline import Pipeline def test_monitor_records_turn_with_timed_steps(): async def go(): mon = Monitor() pipe = Pipeline( brain=MockBrain(), stt=MockSTT(script=["안녕"], interval=0.01), tts=MockTTS(), monitor=mon, ) await asyncio.wait_for(pipe.run(), timeout=5) return mon mon = asyncio.run(go()) snap = mon.snapshot() assert snap["status"]["turns_total"] == 1 assert snap["status"]["running"] is False # cleaned up after run assert len(snap["turns"]) == 1 turn = snap["turns"][0] assert turn["heard"] == "안녕" # what it heard assert turn["reply"] # what it answered assert turn["status"] == "ok" # it worked assert turn["total_ms"] >= 0 # step-by-step: every stage is named and timed names = [s["name"] for s in turn["steps"]] assert names == ["화면 맥락", "두뇌(생각)", "응답(TTS/전송)"] assert all(s["ok"] is True for s in turn["steps"]) assert all(s["ms"] >= 0 for s in turn["steps"]) def test_monitor_marks_errors(): class BoomBrain(MockBrain): async def respond(self, user_text, screen, history): raise RuntimeError("boom") async def go(): mon = Monitor() pipe = Pipeline( brain=BoomBrain(), stt=MockSTT(script=["안녕"], interval=0.01), tts=MockTTS(), monitor=mon, ) try: await asyncio.wait_for(pipe.run(), timeout=5) except BaseException: pass # TaskGroup re-raises; we only care about recorded telemetry return mon mon = asyncio.run(go()) snap = mon.snapshot() turn = snap["turns"][0] assert turn["status"] == "error" brain_step = next(s for s in turn["steps"] if s["name"] == "두뇌(생각)") assert brain_step["ok"] is False assert "boom" in brain_step["error"] assert snap["status"]["errors_total"] >= 1 def test_error_turn_increments_errors_total_once(): """The dashboard voice turn (dashboard.voice_turn) records a turn and calls turn.finish(error=...) directly — it never goes through the pipeline's log("error") path. That failure must still land in errors_total, and exactly once no matter how many times the turn is re-published.""" mon = Monitor() turn = mon.turn(source="discord") turn.heard("`코드` 얘기") # touches/publishes again turn.replied("답변") # and again assert mon.status_snapshot()["errors_total"] == 0 turn.finish(error="melo synth failed: KeyError '`'") assert mon.status_snapshot()["errors_total"] == 1 # A stray re-finish/re-publish must not double count. turn.finish(error="melo synth failed: KeyError '`'") turn._touch() assert mon.status_snapshot()["errors_total"] == 1 # The failure is also visible as an error-level event for the live feed. events = mon.snapshot()["events"] assert any(e["type"] == "log" and e["level"] == "error" for e in events) def test_subscriber_receives_live_turn_events(): async def go(): mon = Monitor() q = mon.subscribe() pipe = Pipeline( brain=MockBrain(), stt=MockSTT(script=["안녕"], interval=0.01), tts=MockTTS(), monitor=mon, ) await asyncio.wait_for(pipe.run(), timeout=5) return q q = asyncio.run(go()) events = [] while not q.empty(): events.append(json.loads(q.get_nowait())) types = {e["type"] for e in events} assert "turn" in types # live turn updates were pushed assert "status" in types # listening/running status changes were pushed