Skip to content

Commit b91fbd3

Browse files
authored
Create test_event_recorder.py
1 parent 12fa734 commit b91fbd3

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

tests/test_event_recorder.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"""
2+
IX-HapticSight — Tests for the high-level event recorder.
3+
4+
These tests verify that EventRecorder can:
5+
- buffer structured events in memory
6+
- persist them to JSONL logs
7+
- record canonical decision cycles in order
8+
- capture fault, transition, and execution status events
9+
"""
10+
11+
import os
12+
import sys
13+
from pathlib import Path
14+
from tempfile import TemporaryDirectory
15+
16+
# Make project packages importable without packaging/install
17+
sys.path.insert(0, os.path.abspath("src"))
18+
19+
from ohip.schemas import ( # noqa: E402
20+
ContactPlan,
21+
ConsentMode,
22+
Pose,
23+
RPY,
24+
SafetyLevel,
25+
Vector3,
26+
)
27+
from ohip_runtime.requests import ( # noqa: E402
28+
ConsentAssessment,
29+
CoordinationDecision,
30+
DecisionStatus,
31+
InteractionKind,
32+
InteractionRequest,
33+
PlanningOutcome,
34+
RequestSource,
35+
SafetyAssessment,
36+
)
37+
from ohip_runtime.state import ( # noqa: E402
38+
ExecutionState,
39+
FaultDisposition,
40+
FaultSeverity,
41+
InteractionSession,
42+
InteractionState,
43+
RuntimeFault,
44+
RuntimeHealth,
45+
)
46+
from ohip_logging.events import EventKind # noqa: E402
47+
from ohip_logging.jsonl import load_event_log # noqa: E402
48+
from ohip_logging.recorder import EventRecorder # noqa: E402
49+
50+
51+
POSE_TARGET = Pose(
52+
frame="W",
53+
xyz=Vector3(0.42, -0.18, 1.36),
54+
rpy=RPY(0.0, 0.0, 1.57),
55+
)
56+
57+
58+
def make_session() -> InteractionSession:
59+
return InteractionSession(
60+
session_id="sess-1",
61+
subject_id="person-1",
62+
interaction_state=InteractionState.VERIFY,
63+
execution_state=ExecutionState.READY,
64+
runtime_health=RuntimeHealth.NOMINAL,
65+
safety_level=SafetyLevel.GREEN,
66+
consent_valid=True,
67+
consent_fresh=True,
68+
)
69+
70+
71+
def make_request() -> InteractionRequest:
72+
return InteractionRequest(
73+
request_id="req-1",
74+
session_id="sess-1",
75+
subject_id="person-1",
76+
interaction_kind=InteractionKind.SUPPORT_CONTACT,
77+
source=RequestSource.OPERATOR,
78+
target_name="shoulder",
79+
requested_scope="shoulder_contact",
80+
requires_contact=True,
81+
)
82+
83+
84+
def make_plan() -> ContactPlan:
85+
return ContactPlan(
86+
contact_zone="shoulder_contact",
87+
target=POSE_TARGET,
88+
normal=Vector3(0.0, 0.8, 0.6),
89+
peak_force_N=1.2,
90+
dwell_ms=1500,
91+
approach_speed_mps=0.15,
92+
release_speed_mps=0.20,
93+
impedance={
94+
"normal_N_per_mm": [0.3, 0.6],
95+
"tangential_N_per_mm": [0.1, 0.3],
96+
},
97+
rationale="test support contact",
98+
consent_mode=ConsentMode.EXPLICIT,
99+
)
100+
101+
102+
def make_decision() -> CoordinationDecision:
103+
consent = ConsentAssessment(
104+
request_id="req-1",
105+
status=DecisionStatus.APPROVED,
106+
consent_mode=ConsentMode.EXPLICIT,
107+
consent_valid=True,
108+
consent_fresh=True,
109+
scope_allowed=True,
110+
reason_code="consent_ok",
111+
)
112+
safety = SafetyAssessment(
113+
request_id="req-1",
114+
status=DecisionStatus.APPROVED,
115+
safety_level=SafetyLevel.GREEN,
116+
may_approach=True,
117+
may_contact=True,
118+
requires_retreat=False,
119+
requires_safe_hold=False,
120+
reason_code="safety_ok",
121+
)
122+
planning = PlanningOutcome(
123+
request_id="req-1",
124+
status=DecisionStatus.APPROVED,
125+
reason_code="plan_ready",
126+
plan=make_plan(),
127+
degraded=False,
128+
)
129+
return CoordinationDecision(
130+
request_id="req-1",
131+
status=DecisionStatus.APPROVED,
132+
reason_code="consent_ok | safety_ok | plan_ready",
133+
consent=consent,
134+
safety=safety,
135+
planning=planning,
136+
)
137+
138+
139+
def test_event_recorder_buffers_single_request_event():
140+
recorder = EventRecorder()
141+
request = make_request()
142+
143+
event = recorder.record_request(request, persist=False)
144+
145+
assert event.kind == EventKind.REQUEST_RECEIVED
146+
assert len(recorder.buffer()) == 1
147+
assert recorder.buffer()[0].event_id == "req-1:request"
148+
149+
150+
def test_event_recorder_records_decision_cycle_in_order():
151+
recorder = EventRecorder()
152+
session = make_session()
153+
request = make_request()
154+
decision = make_decision()
155+
156+
events = recorder.record_decision_cycle(
157+
session=session,
158+
request=request,
159+
decision=decision,
160+
persist=False,
161+
)
162+
163+
assert len(events) == 5
164+
assert [event.kind for event in events] == [
165+
EventKind.REQUEST_RECEIVED,
166+
EventKind.CONSENT_EVALUATED,
167+
EventKind.SAFETY_EVALUATED,
168+
EventKind.PLAN_CREATED,
169+
EventKind.COORDINATION_DECIDED,
170+
]
171+
172+
buffered = recorder.buffer()
173+
assert len(buffered) == 5
174+
assert buffered[-1].reason_code == "consent_ok | safety_ok | plan_ready"
175+
176+
177+
def test_event_recorder_records_fault_transition_and_execution_status():
178+
recorder = EventRecorder()
179+
session = make_session()
180+
session.active_plan_id = "req-1"
181+
182+
fault = RuntimeFault(
183+
fault_id="fault-1",
184+
reason_code="overforce",
185+
severity=FaultSeverity.ABORT,
186+
disposition=FaultDisposition.RETREAT,
187+
source="safety",
188+
requires_retreat=True,
189+
)
190+
session.apply_fault(fault)
191+
192+
fault_event = recorder.record_fault(session=session, fault=fault, persist=False)
193+
assert fault_event.kind == EventKind.RETREAT_STATUS
194+
195+
transition_event = recorder.record_state_transition(
196+
event_id="evt-transition-1",
197+
session_id=session.session_id,
198+
from_interaction_state=InteractionState.VERIFY,
199+
to_interaction_state=InteractionState.RETREAT,
200+
from_execution_state=ExecutionState.READY,
201+
to_execution_state=ExecutionState.RETREATING,
202+
runtime_health=RuntimeHealth.FAULTED,
203+
reason_code="overforce",
204+
persist=False,
205+
)
206+
assert transition_event.kind == EventKind.STATE_TRANSITION
207+
208+
execution_event = recorder.record_execution_status(
209+
event_id="evt-exec-1",
210+
session=session,
211+
request_id="req-1",
212+
reason_code="retreat_started",
213+
accepted=True,
214+
backend_status="running",
215+
progress=0.4,
216+
persist=False,
217+
)
218+
assert execution_event.kind == EventKind.EXECUTION_STATUS
219+
assert execution_event.details["progress"] == 0.4
220+
221+
assert len(recorder.buffer()) == 3
222+
223+
224+
def test_event_recorder_persists_to_jsonl_log():
225+
session = make_session()
226+
request = make_request()
227+
decision = make_decision()
228+
229+
with TemporaryDirectory() as tmpdir:
230+
log_path = Path(tmpdir) / "runtime_events.jsonl"
231+
recorder = EventRecorder.from_path(log_path)
232+
233+
recorder.record_decision_cycle(
234+
session=session,
235+
request=request,
236+
decision=decision,
237+
persist=True,
238+
)
239+
240+
assert log_path.exists() is True
241+
242+
loaded = load_event_log(log_path)
243+
assert len(loaded) == 5
244+
assert loaded[0].kind == EventKind.REQUEST_RECEIVED
245+
assert loaded[-1].kind == EventKind.COORDINATION_DECIDED
246+
247+
248+
def test_persist_buffer_writes_buffered_events_without_clearing():
249+
session = make_session()
250+
request = make_request()
251+
decision = make_decision()
252+
253+
with TemporaryDirectory() as tmpdir:
254+
log_path = Path(tmpdir) / "buffered_runtime_events.jsonl"
255+
recorder = EventRecorder.from_path(log_path)
256+
257+
recorder.record_decision_cycle(
258+
session=session,
259+
request=request,
260+
decision=decision,
261+
persist=False,
262+
)
263+
264+
assert len(recorder.buffer()) == 5
265+
assert log_path.exists() is False
266+
267+
written = recorder.persist_buffer()
268+
assert written == 5
269+
270+
loaded = load_event_log(log_path)
271+
assert len(loaded) == 5
272+
assert len(recorder.buffer()) == 5
273+
274+
recorder.clear_buffer()
275+
assert recorder.buffer() == []

0 commit comments

Comments
 (0)