Skip to content

Commit d654f58

Browse files
authored
Create test_runtime_service.py
1 parent e642ce6 commit d654f58

1 file changed

Lines changed: 314 additions & 0 deletions

File tree

tests/test_runtime_service.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"""
2+
IX-HapticSight — Tests for the high-level runtime service.
3+
4+
These tests verify that RuntimeService can:
5+
- load and update explicit session state
6+
- record the full decision trail for one request
7+
- submit executable plans to an execution adapter
8+
- persist structured events to JSONL
9+
- handle blocked requests conservatively
10+
- drive abort and safe-hold flows through the same service layer
11+
"""
12+
13+
import os
14+
import sys
15+
from pathlib import Path
16+
from tempfile import TemporaryDirectory
17+
18+
# Make project packages importable without packaging/install
19+
sys.path.insert(0, os.path.abspath("src"))
20+
21+
from ohip.consent_manager import ConsentManager # noqa: E402
22+
from ohip.contact_planner import ContactPlanner # noqa: E402
23+
from ohip.safety_gate import SafetyGate # noqa: E402
24+
from ohip.schemas import ( # noqa: E402
25+
Nudge,
26+
NudgeLevel,
27+
Pose,
28+
RPY,
29+
SafetyLevel,
30+
Vector3,
31+
)
32+
from ohip_interfaces.simulated_execution_adapter import ( # noqa: E402
33+
SimulatedExecutionAdapter,
34+
)
35+
from ohip_logging.events import EventKind # noqa: E402
36+
from ohip_logging.jsonl import load_event_log # noqa: E402
37+
from ohip_logging.recorder import EventRecorder # noqa: E402
38+
from ohip_runtime.coordinator import RuntimeCoordinator # noqa: E402
39+
from ohip_runtime.requests import ( # noqa: E402
40+
InteractionKind,
41+
InteractionRequest,
42+
RequestSource,
43+
)
44+
from ohip_runtime.runtime_service import RuntimeService # noqa: E402
45+
from ohip_runtime.session_store import SessionStore # noqa: E402
46+
from ohip_runtime.state import ( # noqa: E402
47+
ExecutionState,
48+
InteractionSession,
49+
InteractionState,
50+
RuntimeHealth,
51+
)
52+
53+
54+
POSE_START = Pose(
55+
frame="W",
56+
xyz=Vector3(0.10, 0.00, 1.00),
57+
rpy=RPY(0.0, 0.0, 0.0),
58+
)
59+
60+
POSE_TARGET = Pose(
61+
frame="W",
62+
xyz=Vector3(0.42, -0.18, 1.36),
63+
rpy=RPY(0.0, 0.0, 1.57),
64+
)
65+
66+
67+
def risk_green(_pose: Pose) -> SafetyLevel:
68+
return SafetyLevel.GREEN
69+
70+
71+
def make_envelopes() -> dict:
72+
return {
73+
"defaults": {
74+
"social_touch_profile": "default_social",
75+
},
76+
"profiles": {
77+
"default_social": {
78+
"max_force_N": 1.2,
79+
"dwell_ms_min": 1000,
80+
"dwell_ms_max": 3000,
81+
"approach_speed_mps": 0.15,
82+
"release_speed_mps": 0.20,
83+
"impedance": {
84+
"normal_N_per_mm": [0.3, 0.6],
85+
"tangential_N_per_mm": [0.1, 0.3],
86+
},
87+
}
88+
},
89+
"safety": {
90+
"red_stop_ms": 100,
91+
},
92+
}
93+
94+
95+
def make_session() -> InteractionSession:
96+
return InteractionSession(
97+
session_id="sess-1",
98+
subject_id="person-1",
99+
interaction_state=InteractionState.IDLE,
100+
execution_state=ExecutionState.IDLE,
101+
runtime_health=RuntimeHealth.NOMINAL,
102+
safety_level=SafetyLevel.GREEN,
103+
consent_valid=False,
104+
consent_fresh=False,
105+
)
106+
107+
108+
def make_contact_request() -> InteractionRequest:
109+
return InteractionRequest(
110+
request_id="req-contact-1",
111+
session_id="sess-1",
112+
subject_id="person-1",
113+
interaction_kind=InteractionKind.SUPPORT_CONTACT,
114+
source=RequestSource.OPERATOR,
115+
target_name="shoulder",
116+
requested_scope="shoulder_contact",
117+
requires_contact=True,
118+
)
119+
120+
121+
def make_nudge() -> Nudge:
122+
return Nudge(
123+
level=NudgeLevel.GREEN,
124+
target=POSE_TARGET,
125+
normal=Vector3(0.0, 0.8, 0.6),
126+
rationale="test shoulder support",
127+
priority=0.9,
128+
expires_in_ms=500,
129+
)
130+
131+
132+
def build_service(log_path: Path):
133+
envelopes = make_envelopes()
134+
consent_manager = ConsentManager()
135+
planner = ContactPlanner(envelopes)
136+
gate = SafetyGate(envelopes)
137+
coordinator = RuntimeCoordinator(
138+
consent_manager=consent_manager,
139+
contact_planner=planner,
140+
safety_gate=gate,
141+
risk_query=risk_green,
142+
)
143+
store = SessionStore()
144+
recorder = EventRecorder.from_path(log_path)
145+
adapter = SimulatedExecutionAdapter()
146+
service = RuntimeService(
147+
session_store=store,
148+
coordinator=coordinator,
149+
recorder=recorder,
150+
execution_adapter=adapter,
151+
)
152+
return service, consent_manager, recorder, adapter
153+
154+
155+
def test_runtime_service_executes_approved_request_and_logs_events():
156+
with TemporaryDirectory() as tmpdir:
157+
log_path = Path(tmpdir) / "runtime_events.jsonl"
158+
service, consent_manager, recorder, _adapter = build_service(log_path)
159+
160+
service.upsert_session(make_session())
161+
162+
consent_manager.grant_explicit(
163+
subject_id="person-1",
164+
scopes=["shoulder_contact"],
165+
source="verbal",
166+
)
167+
168+
result = service.handle_request(
169+
request=make_contact_request(),
170+
nudge=make_nudge(),
171+
start_pose=POSE_START,
172+
)
173+
174+
assert result.decision.status.name == "APPROVED"
175+
assert result.decision.executable is True
176+
assert result.executed is True
177+
assert result.execution_response is not None
178+
assert result.execution_response.accepted is True
179+
180+
session = service.require_session("sess-1")
181+
assert session.active_plan_id == "req-contact-1"
182+
assert session.active_fault is None
183+
assert session.consent_valid is True
184+
assert session.consent_fresh is True
185+
186+
buffered = recorder.buffer()
187+
assert len(buffered) == 6
188+
assert [event.kind for event in buffered] == [
189+
EventKind.REQUEST_RECEIVED,
190+
EventKind.CONSENT_EVALUATED,
191+
EventKind.SAFETY_EVALUATED,
192+
EventKind.PLAN_CREATED,
193+
EventKind.COORDINATION_DECIDED,
194+
EventKind.EXECUTION_STATUS,
195+
]
196+
197+
loaded = load_event_log(log_path)
198+
assert len(loaded) == 6
199+
assert loaded[-1].kind == EventKind.EXECUTION_STATUS
200+
assert loaded[-1].details["accepted"] is True
201+
assert loaded[-1].details["backend_status"] == "ACCEPTED"
202+
203+
204+
def test_runtime_service_blocks_missing_consent_and_records_fault():
205+
with TemporaryDirectory() as tmpdir:
206+
log_path = Path(tmpdir) / "runtime_events_denied.jsonl"
207+
service, _consent_manager, recorder, _adapter = build_service(log_path)
208+
209+
service.upsert_session(make_session())
210+
211+
result = service.handle_request(
212+
request=make_contact_request(),
213+
nudge=make_nudge(),
214+
start_pose=POSE_START,
215+
)
216+
217+
assert result.decision.status.name == "DENIED"
218+
assert result.executed is False
219+
assert result.execution_response is None
220+
221+
session = service.require_session("sess-1")
222+
assert session.active_fault is not None
223+
assert session.runtime_health == RuntimeHealth.BLOCKED
224+
assert session.active_fault.reason_code == "consent_missing_or_invalid"
225+
226+
buffered = recorder.buffer()
227+
assert len(buffered) == 5
228+
assert [event.kind for event in buffered] == [
229+
EventKind.REQUEST_RECEIVED,
230+
EventKind.CONSENT_EVALUATED,
231+
EventKind.SAFETY_EVALUATED,
232+
EventKind.COORDINATION_DECIDED,
233+
EventKind.FAULT_APPLIED,
234+
]
235+
236+
loaded = load_event_log(log_path)
237+
assert len(loaded) == 5
238+
assert loaded[-1].kind == EventKind.FAULT_APPLIED
239+
assert loaded[-1].reason_code == "consent_missing_or_invalid"
240+
241+
242+
def test_runtime_service_abort_session_records_transition_and_status():
243+
with TemporaryDirectory() as tmpdir:
244+
log_path = Path(tmpdir) / "runtime_abort.jsonl"
245+
service, consent_manager, recorder, _adapter = build_service(log_path)
246+
247+
service.upsert_session(make_session())
248+
consent_manager.grant_explicit(
249+
subject_id="person-1",
250+
scopes=["shoulder_contact"],
251+
source="verbal",
252+
)
253+
254+
result = service.handle_request(
255+
request=make_contact_request(),
256+
nudge=make_nudge(),
257+
start_pose=POSE_START,
258+
)
259+
assert result.executed is True
260+
261+
response = service.abort_session(
262+
session_id="sess-1",
263+
reason_code="operator_abort",
264+
)
265+
assert response is not None
266+
assert response.accepted is True
267+
assert response.status.name == "ABORTED"
268+
269+
session = service.require_session("sess-1")
270+
assert session.execution_state == ExecutionState.ABORTING
271+
272+
buffered = recorder.buffer()
273+
assert buffered[-2].kind == EventKind.STATE_TRANSITION
274+
assert buffered[-2].reason_code == "operator_abort"
275+
assert buffered[-1].kind == EventKind.EXECUTION_STATUS
276+
assert buffered[-1].details["backend_status"] == "ABORTED"
277+
278+
279+
def test_runtime_service_safe_hold_session_records_transition_and_status():
280+
with TemporaryDirectory() as tmpdir:
281+
log_path = Path(tmpdir) / "runtime_safe_hold.jsonl"
282+
service, consent_manager, recorder, _adapter = build_service(log_path)
283+
284+
service.upsert_session(make_session())
285+
consent_manager.grant_explicit(
286+
subject_id="person-1",
287+
scopes=["shoulder_contact"],
288+
source="verbal",
289+
)
290+
291+
result = service.handle_request(
292+
request=make_contact_request(),
293+
nudge=make_nudge(),
294+
start_pose=POSE_START,
295+
)
296+
assert result.executed is True
297+
298+
response = service.safe_hold_session(
299+
session_id="sess-1",
300+
reason_code="manual_safe_hold",
301+
)
302+
assert response is not None
303+
assert response.accepted is True
304+
assert response.status.name == "SAFE_HOLD"
305+
306+
session = service.require_session("sess-1")
307+
assert session.interaction_state == InteractionState.SAFE_HOLD
308+
assert session.execution_state == ExecutionState.SAFE_HOLD
309+
310+
buffered = recorder.buffer()
311+
assert buffered[-2].kind == EventKind.STATE_TRANSITION
312+
assert buffered[-2].reason_code == "manual_safe_hold"
313+
assert buffered[-1].kind == EventKind.EXECUTION_STATUS
314+
assert buffered[-1].details["backend_status"] == "SAFE_HOLD"

0 commit comments

Comments
 (0)