Skip to content

Commit abb3614

Browse files
committed
feat(tests): add unit tests for WebSocket broadcast stubs in onboarding and upload flows
1 parent 1539ce1 commit abb3614

2 files changed

Lines changed: 424 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Tests for the M3.6 (#22) WebSocket broadcast stubs in the onboarding flow.
2+
3+
Covers acceptance #3 (each onboarding answer emits a progress event) and
4+
#4 (the end of onboarding emits one ``equipment_kb_card`` event), and #5
5+
(events fire AFTER the MCP write completes — verified by spying on the
6+
order of MCP calls vs broadcast invocations).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from contextlib import asynccontextmanager
12+
from dataclasses import dataclass
13+
from typing import Any
14+
15+
import pytest
16+
from agents.kb_builder.onboarding import service, session_store
17+
from agents.kb_builder.onboarding.questions import QUESTIONS
18+
19+
# ── shared fakes (mirror those in test_service.py to keep this file standalone) ──
20+
21+
22+
@dataclass
23+
class _ToolResult:
24+
content: str = "{}"
25+
is_error: bool = False
26+
27+
28+
class _FakeMCP:
29+
def __init__(self) -> None:
30+
self.calls: list[tuple[str, dict[str, Any]]] = []
31+
32+
async def call_tool(self, name: str, args: dict[str, Any]) -> _ToolResult:
33+
self.calls.append((name, args))
34+
return _ToolResult()
35+
36+
37+
class _FakeRepo:
38+
def __init__(self, row: dict | None) -> None:
39+
self._row = row
40+
41+
async def get_by_cell(self, _cell_id: int):
42+
return self._row
43+
44+
45+
@asynccontextmanager
46+
async def _fake_acquire():
47+
yield object()
48+
49+
50+
class _FakePool:
51+
def acquire(self):
52+
return _fake_acquire()
53+
54+
55+
class _FakeDB:
56+
pool = _FakePool()
57+
58+
59+
def _kb_row(vibration_nominal: float = 4.5) -> dict[str, Any]:
60+
return {
61+
"cell_id": 2,
62+
"structured_data": {
63+
"thresholds": {
64+
"vibration_mm_s": {"nominal": vibration_nominal, "alert": 8.1, "unit": "mm/s"}
65+
}
66+
},
67+
"raw_markdown": "",
68+
"kb_meta": {},
69+
"completeness_score": 0.5,
70+
}
71+
72+
73+
def _patch_db(monkeypatch: pytest.MonkeyPatch, row: dict | None) -> None:
74+
monkeypatch.setattr(service, "db", _FakeDB())
75+
monkeypatch.setattr(service, "KbRepository", lambda _conn: _FakeRepo(row), raising=True)
76+
77+
78+
@pytest.fixture(autouse=True)
79+
def _reset_store():
80+
session_store.SESSIONS.clear()
81+
session_store.SESSIONS_BY_CELL.clear()
82+
yield
83+
session_store.SESSIONS.clear()
84+
session_store.SESSIONS_BY_CELL.clear()
85+
86+
87+
class _Recorder:
88+
def __init__(self) -> None:
89+
self.events: list[tuple[str, dict[str, Any]]] = []
90+
91+
async def __call__(self, event_type: str, payload: dict[str, Any]) -> None:
92+
self.events.append((event_type, payload))
93+
94+
95+
def _patch_broadcast(monkeypatch: pytest.MonkeyPatch) -> _Recorder:
96+
rec = _Recorder()
97+
monkeypatch.setattr(service, "broadcast_stub", rec)
98+
return rec
99+
100+
101+
def _patch_extract(monkeypatch: pytest.MonkeyPatch, patch: dict[str, Any]) -> None:
102+
async def _fake(_answer: str, _hint: str, _cell_id: int) -> dict[str, Any]:
103+
return patch
104+
105+
monkeypatch.setattr(service, "extract_patch", _fake)
106+
107+
108+
# ── tests ────────────────────────────────────────────────────────────────────
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_each_answer_emits_one_kb_progress_event_with_cell_id(monkeypatch):
113+
"""Acceptance #3 + #2 — one progress event per answer, cell_id present."""
114+
row = _kb_row()
115+
_patch_db(monkeypatch, row)
116+
mcp = _FakeMCP()
117+
monkeypatch.setattr(service, "mcp_client", mcp)
118+
recorder = _patch_broadcast(monkeypatch)
119+
_patch_extract(monkeypatch, {"thresholds": {"vibration_mm_s": {"nominal": 5.0, "alert": 9.0}}})
120+
121+
started = await service.start_onboarding(2)
122+
sid = started["session_id"]
123+
124+
# Answer Q1
125+
await service.submit_onboarding_message(sid, "around 5 mm/s")
126+
127+
progress = [p for t, p in recorder.events if p.get("component") == "kb_progress"]
128+
assert len(progress) == 1
129+
assert progress[0]["props"]["cell_id"] == 2
130+
assert progress[0]["agent"] == "kb_builder"
131+
# First step done, rest pending/in_progress
132+
steps = progress[0]["props"]["steps"]
133+
assert len(steps) == len(QUESTIONS)
134+
assert steps[0]["status"] == "done"
135+
assert steps[1]["status"] == "in_progress"
136+
for s in steps[2:]:
137+
assert s["status"] == "pending"
138+
# Sanity — every recorded event uses the ui_render type
139+
assert all(t == "ui_render" for t, _ in recorder.events)
140+
141+
142+
@pytest.mark.asyncio
143+
async def test_completion_emits_progress_plus_equipment_kb_card(monkeypatch):
144+
"""Acceptance #4 — Q4 completion adds an ``equipment_kb_card`` event."""
145+
row = _kb_row()
146+
_patch_db(monkeypatch, row)
147+
mcp = _FakeMCP()
148+
monkeypatch.setattr(service, "mcp_client", mcp)
149+
recorder = _patch_broadcast(monkeypatch)
150+
_patch_extract(monkeypatch, {"thresholds": {"vibration_mm_s": {"nominal": 5.0, "alert": 9.0}}})
151+
152+
started = await service.start_onboarding(2)
153+
sid = started["session_id"]
154+
155+
for _ in range(len(QUESTIONS)):
156+
await service.submit_onboarding_message(sid, "ok")
157+
158+
components = [p["component"] for _, p in recorder.events]
159+
# 4 progress + 1 final card = 5 total.
160+
assert components.count("kb_progress") == len(QUESTIONS)
161+
assert components.count("equipment_kb_card") == 1
162+
# Card is the LAST event, not interleaved.
163+
assert components[-1] == "equipment_kb_card"
164+
165+
card = next(p for _, p in recorder.events if p["component"] == "equipment_kb_card")
166+
assert card["props"]["cell_id"] == 2
167+
assert "thresholds.vibration_mm_s" in card["props"]["highlight_fields"]
168+
assert "failure_patterns" in card["props"]["highlight_fields"]
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_progress_event_fires_after_mcp_write(monkeypatch):
173+
"""Acceptance #5 — the broadcast must be emitted AFTER the MCP write."""
174+
row = _kb_row()
175+
_patch_db(monkeypatch, row)
176+
mcp = _FakeMCP()
177+
monkeypatch.setattr(service, "mcp_client", mcp)
178+
recorder = _patch_broadcast(monkeypatch)
179+
_patch_extract(monkeypatch, {"thresholds": {"vibration_mm_s": {"nominal": 5.0, "alert": 9.0}}})
180+
181+
# Spy on MCP to record how many events had fired by call time.
182+
events_at_call: list[int] = []
183+
original_call = mcp.call_tool
184+
185+
async def _spy(name: str, args: dict[str, Any]) -> _ToolResult:
186+
events_at_call.append(len(recorder.events))
187+
return await original_call(name, args)
188+
189+
monkeypatch.setattr(mcp, "call_tool", _spy)
190+
191+
started = await service.start_onboarding(2)
192+
sid = started["session_id"]
193+
await service.submit_onboarding_message(sid, "ok")
194+
195+
# When the MCP call started, no broadcast had been emitted yet.
196+
assert events_at_call == [0]
197+
# And after the call returned, exactly one broadcast was emitted.
198+
assert len([p for _, p in recorder.events if p["component"] == "kb_progress"]) == 1
199+
200+
201+
@pytest.mark.asyncio
202+
async def test_no_broadcast_when_mcp_write_fails(monkeypatch):
203+
"""Failed MCP write must NOT emit a misleading progress event."""
204+
row = _kb_row()
205+
_patch_db(monkeypatch, row)
206+
207+
class _FailingMCP:
208+
async def call_tool(self, _name: str, _args: dict[str, Any]) -> _ToolResult:
209+
return _ToolResult(content="boom", is_error=True)
210+
211+
monkeypatch.setattr(service, "mcp_client", _FailingMCP())
212+
recorder = _patch_broadcast(monkeypatch)
213+
_patch_extract(monkeypatch, {"thresholds": {"vibration_mm_s": {"nominal": 5.0, "alert": 9.0}}})
214+
215+
started = await service.start_onboarding(2)
216+
sid = started["session_id"]
217+
218+
from core.exceptions import ValidationFailedError
219+
220+
with pytest.raises(ValidationFailedError):
221+
await service.submit_onboarding_message(sid, "ok")
222+
223+
assert recorder.events == []

0 commit comments

Comments
 (0)