feat: spawn deterministic grant scene

This commit is contained in:
Даниил Грабарь
2026-11-08 10:49:13 +10:00
parent e29ae921c4
commit 814922f8ca
2 changed files with 371 additions and 0 deletions
@@ -0,0 +1,172 @@
"""The spawner sends every node once and only signals readiness at the end."""
from pathlib import Path
import pytest
from ament_index_python.packages import get_package_share_directory
from iiwa_experement.scenario import ScenarioError, load_scenario
from iiwa_experement.scene_spawner import (
Readiness,
ScenePlan,
SpawnError,
build_plan,
execute_plan,
node_name,
spawn_nodes,
)
from iiwa_experement.webots_nodes import build_nodes
PACKAGE = Path(__file__).resolve().parents[1]
SCENES = PACKAGE / 'config' / 'scenes.yaml'
ASSET_ROOT = (
Path(get_package_share_directory('iiwa_description')) / 'objects' / 'grant'
)
class FakeLogger:
def __init__(self):
self.messages = []
def info(self, message):
self.messages.append(message)
class FakeNode:
def __init__(self):
self.logger = FakeLogger()
def get_logger(self):
return self.logger
class FakeFuture:
def __init__(self, behavior):
self.behavior = behavior
self.completed = behavior != 'timeout'
def done(self):
return self.completed
def result(self):
if isinstance(self.behavior, Exception):
raise self.behavior
if self.behavior == 'reject':
return type('Response', (), {'success': False})()
return type('Response', (), {'success': True})()
class FakeClient:
def __init__(self, behaviors=(), available=True):
self.behaviors = list(behaviors)
self.available = available
self.calls = []
self.readiness_during_calls = []
self.readiness = None
def wait_for_service(self, timeout_sec):
return self.available
def call_async(self, request):
self.calls.append(request.data)
if self.readiness is not None:
self.readiness_during_calls.append(self.readiness.ready)
index = len(self.calls) - 1
behavior = self.behaviors[index] if index < len(self.behaviors) else 'ok'
return FakeFuture(behavior)
def fake_spin(node, future, timeout_sec):
return None
def run_plan(nodes, client, readiness=None, **kwargs):
plan = ScenePlan(
experiment_id=1,
spec=load_scenario(1, SCENES),
nodes=list(nodes),
readiness=readiness or Readiness(),
)
client.readiness = plan.readiness
execute_plan(
plan,
client,
FakeNode(),
spin_until_future_complete=fake_spin,
**kwargs,
)
return plan
def test_successful_spawn_sends_build_nodes_in_order_and_signals_at_the_end():
nodes = build_nodes(load_scenario(6, SCENES), ASSET_ROOT)
client = FakeClient()
plan = run_plan(nodes, client)
assert client.calls == nodes
assert client.readiness_during_calls == [False] * len(nodes)
assert plan.readiness.ready is True
assert plan.readiness.message == f'experiment 1: {len(nodes)} objects spawned'
def test_node_name_is_used_for_progress_and_errors():
assert node_name('Solid {\n name "grant_cap"\n}') == 'grant_cap'
assert node_name('Solid {}') == 'unnamed object'
def test_missing_supervisor_fails_without_spawning():
client = FakeClient(available=False)
with pytest.raises(SpawnError, match='unavailable'):
run_plan(['Solid { name "grant_cap" }'], client)
assert client.calls == []
def test_rejected_object_names_the_last_object_and_keeps_readiness_false():
client = FakeClient(['ok', 'reject'])
readiness = Readiness()
with pytest.raises(SpawnError, match='grant_body'):
run_plan(
['Solid { name "grant_fixture" }', 'Solid { name "grant_body" }'],
client,
readiness,
)
assert len(client.calls) == 2
assert readiness.ready is False
assert 'grant_body' in readiness.message
def test_exception_names_the_last_object():
client = FakeClient(['ok', RuntimeError('service exploded')])
with pytest.raises(SpawnError, match='grant_body.*service exploded'):
run_plan(
['Solid { name "grant_fixture" }', 'Solid { name "grant_body" }'],
client,
)
def test_response_timeout_is_not_retried():
client = FakeClient(['ok', 'timeout', 'ok'])
with pytest.raises(SpawnError, match='grant_body.*timed out.*no retry'):
run_plan(
['Solid { name "grant_fixture" }', 'Solid { name "grant_body" }'],
client,
)
assert len(client.calls) == 2
def test_duplicate_name_rejection_is_not_hidden():
client = FakeClient(['reject'])
with pytest.raises(SpawnError, match='grant_fixture.*rejected'):
run_plan(['Solid { name "grant_fixture" }'], client)
def test_build_plan_rejects_an_invalid_id_before_spawning():
with pytest.raises(ScenarioError):
build_plan(0, SCENES, ASSET_ROOT)
with pytest.raises(ScenarioError):
build_plan(21, SCENES, ASSET_ROOT)
def test_build_plan_uses_the_full_build_nodes_list():
plan = build_plan(19, SCENES, ASSET_ROOT)
assert plan.nodes == build_nodes(plan.spec, ASSET_ROOT)
assert plan.readiness.ready is False