Skip to content

Commit 78fc5b0

Browse files
authored
Create test_replay.py
1 parent 2702e3b commit 78fc5b0

1 file changed

Lines changed: 320 additions & 0 deletions

File tree

tests/test_replay.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"""
2+
IX-HapticSight — Tests for structured event replay helpers.
3+
4+
These tests verify that the replay layer can:
5+
- iterate deterministically through event streams
6+
- slice by session, request, and event kind
7+
- reload from JSONL artifacts
8+
- merge multiple event streams with stable ordering
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+
InteractionRequest,
32+
InteractionKind,
33+
PlanningOutcome,
34+
RequestSource,
35+
SafetyAssessment,
36+
)
37+
from ohip_runtime.state import ( # noqa: E402
38+
ExecutionState,
39+
InteractionSession,
40+
InteractionState,
41+
RuntimeHealth,
42+
)
43+
from ohip_logging.events import ( # noqa: E402
44+
EventKind,
45+
event_from_consent_assessment,
46+
event_from_coordination_decision,
47+
event_from_planning_outcome,
48+
event_from_request,
49+
event_from_safety_assessment,
50+
execution_status_event,
51+
state_transition_event,
52+
)
53+
from ohip_logging.jsonl import write_event_log # noqa: E402
54+
from ohip_logging.replay import ( # noqa: E402
55+
EventReplay,
56+
ReplayCursor,
57+
merge_replay_streams,
58+
)
59+
60+
61+
POSE_TARGET = Pose(
62+
frame="W",
63+
xyz=Vector3(0.42, -0.18, 1.36),
64+
rpy=RPY(0.0, 0.0, 1.57),
65+
)
66+
67+
68+
def make_session(session_id: str = "sess-1") -> InteractionSession:
69+
return InteractionSession(
70+
session_id=session_id,
71+
subject_id=f"{session_id}-person",
72+
interaction_state=InteractionState.VERIFY,
73+
execution_state=ExecutionState.READY,
74+
runtime_health=RuntimeHealth.NOMINAL,
75+
safety_level=SafetyLevel.GREEN,
76+
consent_valid=True,
77+
consent_fresh=True,
78+
)
79+
80+
81+
def make_request(request_id: str = "req-1", session_id: str = "sess-1") -> InteractionRequest:
82+
return InteractionRequest(
83+
request_id=request_id,
84+
session_id=session_id,
85+
subject_id=f"{session_id}-person",
86+
interaction_kind=InteractionKind.SUPPORT_CONTACT,
87+
source=RequestSource.OPERATOR,
88+
target_name="shoulder",
89+
requested_scope="shoulder_contact",
90+
requires_contact=True,
91+
)
92+
93+
94+
def make_plan() -> ContactPlan:
95+
return ContactPlan(
96+
contact_zone="shoulder_contact",
97+
target=POSE_TARGET,
98+
normal=Vector3(0.0, 0.8, 0.6),
99+
max_force_N=1.2,
100+
dwell_ms=(1000, 3000),
101+
impedance={
102+
"normal_N_per_mm": [0.3, 0.6],
103+
"tangential_N_per_mm": [0.1, 0.3],
104+
},
105+
consent_mode=ConsentMode.EXPLICIT,
106+
)
107+
108+
109+
def make_decision(request_id: str = "req-1") -> CoordinationDecision:
110+
consent = ConsentAssessment(
111+
request_id=request_id,
112+
status=DecisionStatus.APPROVED,
113+
consent_mode=ConsentMode.EXPLICIT,
114+
consent_valid=True,
115+
consent_fresh=True,
116+
scope_allowed=True,
117+
reason_code="consent_ok",
118+
)
119+
safety = SafetyAssessment(
120+
request_id=request_id,
121+
status=DecisionStatus.APPROVED,
122+
safety_level=SafetyLevel.GREEN,
123+
may_approach=True,
124+
may_contact=True,
125+
requires_retreat=False,
126+
requires_safe_hold=False,
127+
reason_code="safety_ok",
128+
)
129+
planning = PlanningOutcome(
130+
request_id=request_id,
131+
status=DecisionStatus.APPROVED,
132+
reason_code="plan_ready",
133+
plan=make_plan(),
134+
degraded=False,
135+
)
136+
return CoordinationDecision(
137+
request_id=request_id,
138+
status=DecisionStatus.APPROVED,
139+
reason_code="consent_ok | safety_ok | plan_ready",
140+
consent=consent,
141+
safety=safety,
142+
planning=planning,
143+
)
144+
145+
146+
def make_event_sequence(session_id: str = "sess-1", request_id: str = "req-1"):
147+
session = make_session(session_id=session_id)
148+
request = make_request(request_id=request_id, session_id=session_id)
149+
decision = make_decision(request_id=request_id)
150+
151+
return [
152+
event_from_request(request),
153+
event_from_consent_assessment(session, decision.consent),
154+
event_from_safety_assessment(session, decision.safety),
155+
event_from_planning_outcome(session, decision.planning),
156+
state_transition_event(
157+
event_id=f"{request_id}:transition:verify_to_approach",
158+
session_id=session_id,
159+
from_interaction_state=InteractionState.VERIFY,
160+
to_interaction_state=InteractionState.APPROACH,
161+
from_execution_state=ExecutionState.READY,
162+
to_execution_state=ExecutionState.READY,
163+
runtime_health=RuntimeHealth.NOMINAL,
164+
reason_code="planner_ready",
165+
),
166+
event_from_coordination_decision(session, decision),
167+
execution_status_event(
168+
event_id=f"{request_id}:execution:start",
169+
session_id=session_id,
170+
request_id=request_id,
171+
interaction_state=InteractionState.APPROACH,
172+
execution_state=ExecutionState.EXECUTING,
173+
runtime_health=RuntimeHealth.NOMINAL,
174+
reason_code="execution_started",
175+
safety_level=SafetyLevel.GREEN,
176+
accepted=True,
177+
backend_status="running",
178+
progress=0.25,
179+
),
180+
]
181+
182+
183+
def test_replay_basic_iteration_and_summary():
184+
events = make_event_sequence()
185+
replay = EventReplay(events, source_label="unit-test")
186+
187+
assert len(replay) == 7
188+
assert replay.first().kind == EventKind.REQUEST_RECEIVED
189+
assert replay.last().kind == EventKind.EXECUTION_STATUS
190+
191+
summary = replay.summary()
192+
assert summary["source_label"] == "unit-test"
193+
assert summary["event_count"] == 7
194+
assert summary["session_ids"] == ["sess-1"]
195+
assert summary["request_ids"] == ["req-1"]
196+
assert summary["kind_counts"]["REQUEST_RECEIVED"] == 1
197+
assert summary["kind_counts"]["COORDINATION_DECIDED"] == 1
198+
199+
200+
def test_replay_cursor_advances_deterministically():
201+
replay = EventReplay(make_event_sequence())
202+
cursor = ReplayCursor(index=0)
203+
204+
kinds = []
205+
while True:
206+
event, cursor = replay.next_from(cursor)
207+
if event is None:
208+
break
209+
kinds.append(event.kind.value)
210+
211+
assert kinds == [
212+
"REQUEST_RECEIVED",
213+
"CONSENT_EVALUATED",
214+
"SAFETY_EVALUATED",
215+
"PLAN_CREATED",
216+
"STATE_TRANSITION",
217+
"COORDINATION_DECIDED",
218+
"EXECUTION_STATUS",
219+
]
220+
221+
event, same_cursor = replay.next_from(cursor)
222+
assert event is None
223+
assert same_cursor.index == cursor.index
224+
225+
226+
def test_replay_filters_by_session_request_and_kind():
227+
events_a = make_event_sequence(session_id="sess-1", request_id="req-1")
228+
events_b = make_event_sequence(session_id="sess-2", request_id="req-2")
229+
replay = EventReplay(events_a + events_b)
230+
231+
session_slice = replay.by_session("sess-2")
232+
assert len(session_slice) == 7
233+
assert session_slice.session_ids() == ["sess-2"]
234+
assert session_slice.request_ids() == ["req-2"]
235+
236+
request_slice = replay.by_request("req-1")
237+
assert len(request_slice) == 7
238+
assert request_slice.session_ids() == ["sess-1"]
239+
240+
kinds_slice = replay.by_kind(EventKind.REQUEST_RECEIVED, EventKind.COORDINATION_DECIDED)
241+
assert len(kinds_slice) == 4
242+
assert kinds_slice.kinds() == ["REQUEST_RECEIVED", "COORDINATION_DECIDED"]
243+
244+
245+
def test_replay_between_event_ids_returns_contiguous_slice():
246+
replay = EventReplay(make_event_sequence())
247+
slice_ = replay.between_event_ids(
248+
"req-1:consent",
249+
"req-1:decision",
250+
)
251+
252+
assert len(slice_) == 5
253+
assert slice_.first().kind == EventKind.CONSENT_EVALUATED
254+
assert slice_.last().kind == EventKind.COORDINATION_DECIDED
255+
256+
slice_without_end = replay.between_event_ids(
257+
"req-1:consent",
258+
"req-1:decision",
259+
include_end=False,
260+
name="consent_to_predecision",
261+
)
262+
assert len(slice_without_end) == 4
263+
assert slice_without_end.name == "consent_to_predecision"
264+
assert slice_without_end.last().kind == EventKind.STATE_TRANSITION
265+
266+
267+
def test_replay_loads_from_jsonl_artifact():
268+
events = make_event_sequence()
269+
270+
with TemporaryDirectory() as tmpdir:
271+
log_path = Path(tmpdir) / "events.jsonl"
272+
written = write_event_log(log_path, events)
273+
assert written == 7
274+
275+
replay = EventReplay.from_jsonl(log_path)
276+
assert len(replay) == 7
277+
assert replay.source_label.endswith("events.jsonl")
278+
assert replay.first().event_id == "req-1:request"
279+
assert replay.last().event_id == "req-1:execution:start"
280+
281+
282+
def test_merge_replay_streams_orders_by_time_then_event_id():
283+
stream1 = make_event_sequence(session_id="sess-a", request_id="req-a")
284+
stream2 = make_event_sequence(session_id="sess-b", request_id="req-b")
285+
286+
# Force deterministic overlap ordering by timestamp
287+
e1 = stream1[0]
288+
e2 = stream2[0]
289+
290+
event_a = type(e1)(
291+
event_id="a-event",
292+
kind=e1.kind,
293+
session_id=e1.session_id,
294+
request_id=e1.request_id,
295+
interaction_state=e1.interaction_state,
296+
execution_state=e1.execution_state,
297+
runtime_health=e1.runtime_health,
298+
reason_code=e1.reason_code,
299+
created_at_utc_s=1000.0,
300+
details=e1.details,
301+
)
302+
event_b = type(e2)(
303+
event_id="b-event",
304+
kind=e2.kind,
305+
session_id=e2.session_id,
306+
request_id=e2.request_id,
307+
interaction_state=e2.interaction_state,
308+
execution_state=e2.execution_state,
309+
runtime_health=e2.runtime_health,
310+
reason_code=e2.reason_code,
311+
created_at_utc_s=1000.0,
312+
details=e2.details,
313+
)
314+
315+
merged = merge_replay_streams([[event_b], [event_a]], source_label="merged-test")
316+
317+
assert len(merged) == 2
318+
assert merged.source_label == "merged-test"
319+
assert merged.at(0).event_id == "a-event"
320+
assert merged.at(1).event_id == "b-event"

0 commit comments

Comments
 (0)