Files
orisys_arm/station/test/test_motion_commands.py
T

249 lines
16 KiB
Python

"""Exercise routing and stop semantics without launching ROS or moving hardware."""
import threading
from pathlib import Path
from unittest import TestCase
from unittest.mock import patch
from station.control_service import StationService
class Gateway:
active_arm = '89'
def __init__(self): self.calls=[];self.fail_stop=False
def graph(self): return {'nodes': [], 'topics': [], 'services': []}
def states(self): return {}, {}
def require_arm(self, aid, event, *, resume=True): self.calls.append(('require',aid) if resume else ('check_only',aid))
def move_joints(self, arm, joints, speed, event):
self.calls.append(('home',arm['id'],list(joints),speed));return {'ok':True}
def stop_arm(self, arm):
self.calls.append(('stop',arm['id']))
if self.fail_stop: raise RuntimeError('no acknowledgement')
def confirm_stopped(self, arm):
self.calls.append(('confirmed',arm['id']));return {'ok':True,'message':'stopped'}
def close(self): pass
class MotionCommandsTest(TestCase):
def setUp(self):
self.gateway=Gateway();self.service=StationService(gateway=self.gateway)
self.service.site.pop('remote_camera_preview',None)
def tearDown(self): self.service.close()
def test_camera_refresh_checks_both_cameras_without_robot_control(self):
with patch.object(self.service,'_camera',side_effect=[RuntimeError('D405 missing'),{'ok':True,'message':'ready'}]) as camera:
result=self.service.execute('camera_refresh','89','eye_in_hand',{},threading.Event())
self.assertFalse(result['ok']);self.assertEqual(camera.call_count,2)
self.assertFalse(camera.call_args_list[0].args[1]);self.assertTrue(camera.call_args_list[1].args[1])
self.assertEqual(self.gateway.calls,[])
self.assertTrue(result['cameras'][1]['ok'])
def test_enable_reuses_running_stack_without_second_driver(self):
with patch.object(self.gateway,'graph',return_value={'nodes':['controller_manager'],'topics':[],'services':[]}),patch.object(self.service.processes,'start') as start:
result=self.service.execute('real_up','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok']);start.assert_not_called()
self.assertEqual(self.gateway.calls,[('check_only','89')])
self.assertIsNone(self.service._motion_arm)
def test_detection_and_alignment_are_separate_stages(self):
commands=[]
def run(key, argv, event, env, cwd, **kwargs):
commands.append(argv);return {'ok':True}
with patch.object(self.service,'_camera'),patch.object(self.service,'_environment',return_value={}),patch.object(self.service.processes,'run',side_effect=run):
self.service.execute('stage_track','89','eye_in_hand',{},threading.Event())
self.service.execute('stage_locate','89','eye_in_hand',{},threading.Event())
self.assertIn('skip_stage1:=false',commands[0]);self.assertIn('skip_stage2:=true',commands[0])
self.assertIn('run_a7_after_insert:=false',commands[0]);self.assertIn('completion_linger_sec:=0.3',commands[0]);self.assertIn('skip_stage3:=true',commands[0]);self.assertIn('d435_move_only:=false',commands[0])
self.assertIn('skip_stage1:=true',commands[1]);self.assertIn('skip_stage2:=false',commands[1])
self.assertIn('run_a7_after_insert:=false',commands[1]);self.assertIn('no_descent:=false',commands[1]);self.assertIn('skip_stage3:=true',commands[1])
def test_rotation_is_a7_only_and_does_not_open_cameras(self):
with patch.object(self.service,'_rotation_ready'),patch.object(self.service,'_camera') as camera, \
patch.object(self.service,'_environment',return_value={}),patch.object(self.service.processes,'run',return_value={'ok':True}) as run:
self.assertTrue(self.service.execute('stage_screw','89','eye_in_hand',{},threading.Event())['ok'])
self.assertEqual(run.call_args.args[1][-2:],['-m','arm_application.a7_rotation_node'])
camera.assert_not_called()
def test_full_flow_runs_rotation_only_after_alignment_success(self):
from station.tasks import Cancelled
for first_ok,cancelled,expected_runs in ((True,False,2),(False,False,1),(True,True,1)):
event=threading.Event();commands=[]
def run(key,argv,*args,**kwargs):
commands.append(argv)
if cancelled:event.set()
return {'ok':first_ok}
with patch.object(self.service,'_rotation_ready'),patch.object(self.service,'_camera'), \
patch.object(self.service,'_environment',return_value={}),patch.object(self.service.processes,'run',side_effect=run):
if cancelled:
with self.assertRaises(Cancelled):self.service.execute('pipeline','89','eye_in_hand',{},event)
else:self.service.execute('pipeline','89','eye_in_hand',{},event)
self.assertEqual(len(commands),expected_runs)
self.assertIn('no_descent:=false',commands[0])
self.assertIn('run_a7_after_insert:=false',commands[0])
if expected_runs==2:self.assertEqual(commands[1][-1],'arm_application.a7_rotation_node')
def test_rotation_requires_own_typed_gripper_before_any_task(self):
with patch.object(self.service.processes,'run') as run:
with self.assertRaisesRegex(RuntimeError,'平行夹爪'):
self.service.execute('stage_screw','89','eye_in_hand',{},threading.Event())
run.assert_not_called()
def test_home_uses_each_arms_named_initial_pose(self):
import yaml
for aid,arm in self.service.arms.items():
robot=next(row for row in self.service.site['robot']['robots'] if row['sn']==arm['sn'])
path=Path(robot['named_poses_file'])
if not path.is_absolute():path=self.service.root/path
expected=yaml.safe_load(path.read_text())['named_pose_server']['ros__parameters']['named_poses.home']
self.service.execute('reset_home',aid,'eye_in_hand',{},threading.Event())
self.assertEqual(self.gateway.calls[-1][1:3],(aid,expected))
def test_stop_targets_running_arm_even_after_ui_selection_changes(self):
self.service._motion_arm='89'
result=self.service.stop_commands('91')
self.assertTrue(result['ok']);self.assertEqual(self.gateway.calls,[('stop','89'),('confirmed','89')])
def test_failed_stop_blocks_motion_until_stop_confirmed(self):
self.gateway.fail_stop=True
self.assertFalse(self.service.stop_commands('89')['ok'])
with self.assertRaisesRegex(RuntimeError,'停止未确认'):self.service.submit('reset_home','89')
self.gateway.fail_stop=False
self.assertTrue(self.service.stop_commands('89')['ok']);self.assertEqual(self.service._stop_error,'')
def test_shutdown_cancel_does_not_stop_unrelated_controller(self):
self.assertTrue(self.service.stop_commands()['ok']);self.assertEqual(self.gateway.calls,[])
def test_external_camera_is_not_adopted_or_started_again(self):
env={'SITE_CAM_SERIAL':'test-serial','SITE_CAM_NAME':'d405_left'}
with patch.object(self.service,'_environment',return_value=env), \
patch.object(self.gateway,'publisher_count',return_value=1,create=True), \
patch.object(self.gateway,'publisher_nodes',return_value=['foreign_camera'],create=True), \
patch.object(self.service.processes,'start') as start:
with self.assertRaisesRegex(RuntimeError,'外部'):
self.service._camera(self.service.arms['89'],False,threading.Event())
start.assert_not_called()
def test_camera_process_without_frames_is_not_ready(self):
with patch.object(self.service,'_camera_ownership',return_value={'owned':True,'conflict':False}), \
patch.object(self.gateway,'camera_health',return_value={'fresh':False},create=True):
with self.assertRaisesRegex(RuntimeError,'没有新帧'):
self.service._ensure_camera('d405_left',[],{},threading.Event())
def test_camera_refresh_preserves_healthy_matching_stream(self):
with patch.object(self.service,'_camera_ownership',return_value={'owned':True,'conflict':False}), \
patch.object(self.gateway,'camera_health',return_value={'fresh':True,'width':1280,'height':720},create=True), \
patch.object(self.gateway,'publisher_count',return_value=1,create=True), \
patch.object(self.service.processes,'stop') as stop:
result=self.service._ensure_camera('d405_left',['-p','width:=1280','-p','height:=720'],{},threading.Event(),refresh=True)
self.assertTrue(result['ok']);stop.assert_not_called()
def test_camera_refresh_keeps_usb2_fallback_resolution(self):
with patch.object(self.service,'_camera_ownership',return_value={'owned':True,'conflict':False}), \
patch.object(self.gateway,'camera_health',return_value={'fresh':True,'width':640,'height':480},create=True), \
patch.object(self.gateway,'publisher_count',return_value=1,create=True), \
patch.object(self.service.processes,'stop') as stop:
result=self.service._ensure_camera('d405_left',['-p','width:=1280','-p','height:=720'],{},threading.Event(),refresh=True)
self.assertTrue(result['ok']);stop.assert_not_called()
def test_camera_refresh_reconnects_only_camera_even_while_arm_is_enabling(self):
from unittest.mock import Mock
self.service._motion_arm='89'
active=[True]
def snapshot():return {'camera:d405_left':{'running':active[0]}}
def stop(key):
self.assertEqual(key,'camera:d405_left');active[0]=False
def start(*args):active[0]=True;return Mock(),Path('/tmp/fake-camera.log')
with patch.object(self.service,'_camera_ownership',side_effect=[
{'owned':True,'conflict':False}, {'owned':False,'conflict':False},
{'owned':True,'conflict':False}]), \
patch.object(self.gateway,'camera_health',side_effect=[{'fresh':False},{'fresh':True}],create=True), \
patch.object(self.gateway,'clear_camera_health',create=True), \
patch.object(self.gateway,'publisher_count',return_value=0,create=True), \
patch.object(self.service.processes,'snapshot',side_effect=snapshot), \
patch.object(self.service.processes,'stop',side_effect=stop), \
patch.object(self.service.processes,'start',side_effect=start):
self.assertTrue(self.service._ensure_camera('d405_left',[],{},threading.Event(),refresh=True)['ok'])
self.assertEqual(self.gateway.calls,[])
def test_same_name_does_not_prove_camera_ownership(self):
name='d405_left'
with patch.object(self.gateway,'publisher_nodes',return_value=[self.service._camera_node_name(name)],create=True):
self.assertFalse(self.service._camera_ownership(name,{})['owned'])
self.assertTrue(self.service._camera_ownership(name,{'camera:'+name:{'running':True}})['owned'])
with patch.object(self.gateway,'publisher_nodes',return_value=[self.service._camera_node_name(name),'foreign_camera'],create=True):
self.assertFalse(self.service._camera_ownership(name,{'camera:'+name:{'running':True}})['owned'])
def test_independent_commands_can_run_during_tracking(self):
import time
gate=threading.Event()
def execute(action,arm,mode,extras,event):
if action=='stage_track': gate.wait(2)
return {'ok':True}
with patch.object(self.service,'execute',side_effect=execute):
motion=self.service.submit('stage_track','89')
try:
with self.assertRaisesRegex(RuntimeError,'检测跟踪'):
self.service.submit('reset_home','89')
with self.assertRaisesRegex(RuntimeError,'检测跟踪'):
self.service.submit('reset_home','91')
tokens=[self.service.submit('gripper_open','89'),self.service.submit('tactile_reset','89'),
self.service.submit('camera_refresh','89'),
self.service.submit('handeye_show','89'),
self.service.submit('gripper_open','91')]
deadline=time.monotonic()+1
while time.monotonic()<deadline and any(self.service.tasks.snapshot(t)['state'] in ('queued','running') for t in tokens): time.sleep(.01)
self.assertTrue(all(self.service.tasks.snapshot(t)['state']=='succeeded' for t in tokens))
self.assertIn(self.service.tasks.snapshot(motion)['state'],('queued','running'))
finally: gate.set()
def test_feedback_interruption_cancels_task_before_stop_and_cleans_up(self):
started=threading.Event(); release=threading.Event()
def operation(event):
started.set(); release.wait(2); return {'ok':True}
token=self.service.tasks.submit('stage_track',{'robot-control'},operation,context={'arm':'89'})
self.assertTrue(started.wait(1))
def stop(arm):
self.assertTrue(self.service.tasks.snapshot(token)['cancel_requested'])
release.set(); return {'ok':True}
with patch.object(self.gateway,'motion_interruption',return_value='feedback lost',create=True),patch.object(self.service,'stop_commands',side_effect=stop) as stopped:
self.service._check_motion_interruption(); stopped.assert_called_once_with('89')
self.assertEqual(self.service.error,'')
self.assertEqual(self.service._arm_errors['89'],'feedback lost')
import time
deadline=time.monotonic()+2
while self.service.tasks.snapshot(token)['state']=='running' and time.monotonic()<deadline: time.sleep(.01)
self.assertEqual(self.service.tasks.snapshot(token)['state'],'cancelled')
def test_rejected_command_is_logged_before_returning_error(self):
with self.assertRaisesRegex(ValueError,'未注册'):
self.service.submit('invalid_action','89')
events=self.service.snapshot()['operation_log']['events']
own=[r for r in events if r['action']=='invalid_action']
self.assertEqual([r['kind'] for r in own],['received','rejected'])
self.assertIn('未注册',own[-1]['message'])
def test_sensor_refresh_restarts_only_owned_sensor_nodes(self):
from unittest.mock import Mock
records={'tactile:left':{'running':True},'tactile:right':{'running':True},
'stack:89':{'running':True},'gripper:89':{'running':True}}
with patch.object(self.service.processes,'snapshot',return_value=records), \
patch.object(self.service.processes,'stop') as stop, \
patch.object(self.service.processes,'start') as start, \
patch.object(self.service,'_environment',return_value={}), \
patch.object(self.gateway,'publisher_nodes',return_value=[],create=True), \
patch.object(self.gateway,'watch_tactile',create=True):
result=self.service.execute('tactile_refresh','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok'])
self.assertEqual([c.args[0] for c in stop.call_args_list],['tactile:left','tactile:right'])
self.assertEqual(start.call_count,2)
with patch.object(self.service,'execute',return_value={'ok':True}):
token=self.service.submit('tactile_refresh','89')
self.assertIsNotNone(self.service.tasks.snapshot(token))
def test_reference_reset_calls_only_owned_sensor_service_without_restarting(self):
reply=type('Reply',(),{'success':True,'message':'reference reset'})()
with patch.object(self.service.processes,'snapshot',return_value={'tactile:left':{'running':True}}), \
patch.object(self.service.processes,'stop') as stop, \
patch.object(self.service.processes,'start') as start, \
patch.object(self.gateway,'call',return_value=reply,create=True) as call, \
patch.object(self.gateway,'clear_tactile_reference',create=True) as clear:
result=self.service.execute('tactile_reset','89','eye_in_hand',{},threading.Event())
self.assertTrue(result['ok']);self.assertIn('right',result['message'])
self.assertEqual(call.call_args.args[1],'/tactile/left/reset_reference')
clear.assert_called_once_with('left');start.assert_not_called();stop.assert_not_called()
from station.command_policy import resources_for
for action in ('stage_track','stage_locate','stage_screw','pipeline'):
self.assertFalse(resources_for('tactile_reset','89') & resources_for(action,'89'))