Initial import of ORISYS arm workstation
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"""Exercise Qt and backend shutdown with no device transport in the UI."""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from station import runtime_client as runtime
|
||||
runtime.start(offline=True)
|
||||
import station.operator_app as ui
|
||||
app=ui.QApplication.instance();app.setFont(ui.QFont('Noto Sans SC',11));app.setStyleSheet(ui._STYLE)
|
||||
window=ui.StationApp();window.resize(1440,900);window.show()
|
||||
window._apply_theme('dark',persist=False)
|
||||
assert app.property('darkTheme') is True
|
||||
assert window._theme_selector.currentData()=='dark'
|
||||
window._apply_theme('light',persist=False)
|
||||
assert app.property('darkTheme') is False
|
||||
assert window._theme_selector.currentData()=='light'
|
||||
assert 'rclpy' not in sys.modules
|
||||
assert 'serial' not in sys.modules
|
||||
assert 'station.operator_console' not in sys.modules
|
||||
assert set(window._pages) == {'main','calibration','settings','ros_monitor'}
|
||||
# A photo must preserve the current RGB frame; no capture/device operation is needed.
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
sample=bytes([12,84,192])*(32*18)
|
||||
selected=next(cam for _page,cam,_label in window._camera_labels if cam.get('arm')==window._arm)
|
||||
with tempfile.TemporaryDirectory() as pictures:
|
||||
with patch.object(ui.Path,'home',return_value=ui.Path(pictures)), patch.object(ui.camera_view,'previews',return_value={selected['id']+'_color':(sample,32,18)}):
|
||||
window._take_photo()
|
||||
photos=list(ui.Path(pictures).rglob('*.png'))
|
||||
assert len(photos)==1
|
||||
image=ui.QImage(str(photos[0]))
|
||||
assert (image.width(),image.height())==(32,18)
|
||||
assert image.pixelColor(0,0).getRgb()[:3]==(12,84,192)
|
||||
window._open_camera(selected['id'])
|
||||
assert selected['id'] in window._camera_windows
|
||||
window._camera_windows[selected['id']].close()
|
||||
assert not window._camera_windows
|
||||
errors=[];ticks=[]
|
||||
duration=float(os.environ.get("ORISYS_GUI_TEST_SECONDS","2.2"))
|
||||
started=time.monotonic()
|
||||
frames=[bytes([v,40,80])*(320*180) for v in (30,70,120)]
|
||||
def tick():
|
||||
ticks.append(time.monotonic())
|
||||
if duration>10:
|
||||
if len(ticks)%100==0:
|
||||
window._show_page(("main","calibration")[(len(ticks)//100)%2])
|
||||
for key in ui.camera_view._PREVIEW_WANT:
|
||||
ui.camera_view._PREVIEW[key]=(frames[len(ticks)%3],320,180)
|
||||
ui.camera_view._META[key]=(320,180,20.)
|
||||
original=sys.excepthook
|
||||
def fail(*args): errors.append(str(args[1]));original(*args)
|
||||
sys.excepthook=fail
|
||||
beat=ui.QTimer();beat.timeout.connect(tick);beat.start(50)
|
||||
ui.QTimer.singleShot(200,lambda:window._start(lambda:(time.sleep(.7),{'ok':True,'message':'test'})[1],'test'))
|
||||
ui.QTimer.singleShot(500,lambda:window._show_page('calibration'))
|
||||
ui.QTimer.singleShot(1000,lambda:window._show_page('calibration'))
|
||||
ui.QTimer.singleShot(1500,lambda:window._show_page('ros_monitor'))
|
||||
ui.QTimer.singleShot(1900,lambda:window._show_page('main'))
|
||||
ui.QTimer.singleShot(round(duration*1000),window.close)
|
||||
ui.QTimer.singleShot(round((duration+15)*1000),app.quit)
|
||||
app.exec()
|
||||
assert len(ticks)>20,len(ticks)
|
||||
assert not errors,errors
|
||||
assert runtime._runtime is None
|
||||
assert not any(job.isRunning() for job in window._jobs.values())
|
||||
print('Qt responsive, no device modules, async task and graceful shutdown PASS',
|
||||
{'seconds':round(time.monotonic()-started,2),'ticks':len(ticks),
|
||||
'max_gap':round(max((b-a for a,b in zip(ticks,ticks[1:])),default=0),3),'errors':errors},flush=True)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Isolated DDS-domain integration fixture. All hardware transports are fake."""
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# The caller assigns a private ROS_DOMAIN_ID before importing rclpy.
|
||||
import rclpy
|
||||
from rclpy.executors import MultiThreadedExecutor
|
||||
from rclpy.node import Node
|
||||
from arm_msgs.srv import GripperExecute
|
||||
from arm_application.dh_pgea import GripperState
|
||||
import arm_application.dh_pgea_node as driver
|
||||
|
||||
|
||||
class FakeGripper:
|
||||
writes=[]
|
||||
def __init__(self,**kwargs):
|
||||
self.state=GripperState(init_state=1,grip_state=1,position=300,target_position=300,target_force=33)
|
||||
def open(self): pass
|
||||
def close(self): pass
|
||||
def probe(self): return 1
|
||||
def get_run_states(self,**kwargs): return self.state
|
||||
def initialize(self): self.writes.append('INIT')
|
||||
def move(self,pos,force,speed):
|
||||
self.writes.append((pos,force,speed));self.state.position=pos;self.state.target_position=pos;return True
|
||||
|
||||
|
||||
def main():
|
||||
driver.DhPgeaGripper=FakeGripper
|
||||
rclpy.init(args=['--ros-args','-r','__ns:=/station/grippers/arm_89','-p','auto_init:=false'])
|
||||
device=driver.DhPgeaNode()
|
||||
assert device.get_namespace()=='/station/grippers/arm_89',device.get_namespace()
|
||||
client_node=Node('station_test_client')
|
||||
executor=MultiThreadedExecutor(num_threads=2);executor.add_node(device);executor.add_node(client_node)
|
||||
thread=threading.Thread(target=executor.spin,daemon=True);thread.start()
|
||||
try:
|
||||
client=client_node.create_client(GripperExecute,'/station/grippers/arm_89/execute')
|
||||
assert client.wait_for_service(timeout_sec=3)
|
||||
def command(name,position=0,force=33,speed=25):
|
||||
req=GripperExecute.Request();req.command.command=name;req.command.position=float(position)
|
||||
req.command.force=float(force);req.command.speed=float(speed)
|
||||
future=client.call_async(req);deadline=time.monotonic()+3
|
||||
while not future.done() and time.monotonic()<deadline: time.sleep(.01)
|
||||
assert future.done(),'service did not respond'
|
||||
return future.result()
|
||||
assert command('connect').success
|
||||
assert FakeGripper.writes==[],FakeGripper.writes
|
||||
assert not command('move',1,force=999).success
|
||||
assert not command('move',float('nan')).success
|
||||
assert not command('rotate_to',90).success
|
||||
assert FakeGripper.writes==[]
|
||||
assert command('move',10).success
|
||||
assert FakeGripper.writes==[(500,33,25)],FakeGripper.writes
|
||||
assert command('connect').status.finger_position_per_mille==500.
|
||||
print('ROS typed service, namespace, bounds and no-auto-init PASS',flush=True)
|
||||
finally:
|
||||
executor.shutdown(timeout_sec=3);thread.join(timeout=3)
|
||||
device.destroy_node();client_node.destroy_node();rclpy.shutdown()
|
||||
# Camera subscriptions must follow consumers, not all configured cameras.
|
||||
from station import operator_camera_view as camera
|
||||
cams=[{'id':'left','role':'wrist'},{'id':'right','role':'wrist'},{'id':'sim_wrist','role':'wrist'}]
|
||||
camera.watch(set(),set());node=camera._open_node(cams)
|
||||
try:
|
||||
node.sync_streams();assert node._streams.items=={}
|
||||
camera.watch({'left_color'},set());node.sync_streams()
|
||||
assert set(node._streams.items)=={'left_color'}
|
||||
from sensor_msgs.msg import Image
|
||||
from rclpy.qos import qos_profile_sensor_data
|
||||
publisher=node.create_publisher(Image,'/left/color/image_raw',qos_profile_sensor_data)
|
||||
message=Image();message.width=4;message.height=4;message.step=12;message.encoding='rgb8';message.data=bytes([90,20,10])*16
|
||||
deadline=time.monotonic()+3
|
||||
while 'left_color' not in camera.previews() and time.monotonic()<deadline:
|
||||
publisher.publish(message);rclpy.spin_once(node,timeout_sec=.05);camera.publish_latest()
|
||||
blob,width,height=camera.previews()['left_color']
|
||||
assert (width,height)==(4,4) and blob[:3]==bytes([90,20,10])
|
||||
import tempfile
|
||||
from station.cam_bridge import _export
|
||||
shms={}
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
_export(Path(directory),shms,{})
|
||||
assert shms
|
||||
first=next(iter(shms.values()))[1]
|
||||
assert camera._shm_read(first)[3]==blob
|
||||
finally:
|
||||
for _,shm in shms.values(): shm.close();shm.unlink()
|
||||
camera.watch({'right_depth'},set());node.sync_streams()
|
||||
assert set(node._streams.items)=={'right_depth'}
|
||||
camera.watch(set(),{'left_color'});node.sync_streams()
|
||||
assert set(node._streams.items)=={'left_color'}
|
||||
camera.watch(set(),set());node.sync_streams()
|
||||
assert not node._streams.items and not node._detections.items
|
||||
print('ROS dynamic camera subscriptions PASS',flush=True)
|
||||
finally:
|
||||
node.destroy_node();rclpy.shutdown()
|
||||
|
||||
|
||||
# Exercise native Action feedback/cancellation without a robot controller.
|
||||
from station.ros_gateway import RosGateway
|
||||
from arm_application.site_io import load_site_dict
|
||||
from sensor_msgs.msg import JointState
|
||||
from controller_manager_msgs.srv import ListControllers, SwitchController
|
||||
from controller_manager_msgs.msg import ControllerState
|
||||
from rclpy.action import ActionServer, CancelResponse
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from control_msgs.action import FollowJointTrajectory
|
||||
from station.tasks import Cancelled
|
||||
gateway=RosGateway(load_site_dict())
|
||||
fake=Node('fake_controller')
|
||||
controller_state = ['active']
|
||||
switches = []
|
||||
def controllers(req,resp):
|
||||
state=ControllerState();state.name='rizon_arm_controller';state.state=controller_state[0]
|
||||
state.claimed_interfaces=[f"{arm['sn']}_joint{i}/position" for i in range(1,8)] if state.state=='active' else []
|
||||
resp.controller=[state];return resp
|
||||
fake.create_service(ListControllers,'/controller_manager/list_controllers',controllers)
|
||||
def switch(req,resp):
|
||||
switches.append((list(req.activate_controllers),list(req.deactivate_controllers)))
|
||||
controller_state[0]='inactive' if req.deactivate_controllers else 'active'
|
||||
resp.ok=True;return resp
|
||||
fake.create_service(SwitchController,'/controller_manager/switch_controller',switch)
|
||||
group=ReentrantCallbackGroup()
|
||||
def execute(handle):
|
||||
end=time.monotonic()+.7
|
||||
while time.monotonic()<end:
|
||||
if handle.is_cancel_requested:
|
||||
handle.canceled();return FollowJointTrajectory.Result()
|
||||
time.sleep(.01)
|
||||
handle.succeed();return FollowJointTrajectory.Result()
|
||||
action=ActionServer(fake,FollowJointTrajectory,'/rizon_arm_controller/follow_joint_trajectory',
|
||||
execute_callback=execute,cancel_callback=lambda request:CancelResponse.ACCEPT,callback_group=group)
|
||||
gateway.executor.add_node(fake)
|
||||
arm=next(a for a in gateway.arms if a['id']==gateway.active_arm)
|
||||
msg=JointState();msg.name=[f"{arm['sn']}_joint{i}" for i in range(1,8)];msg.position=[0.]*7
|
||||
try:
|
||||
gateway._joint(msg)
|
||||
assert gateway.move_joints(arm,[.01]*7,.15,threading.Event())['ok']
|
||||
gateway._joint(msg)
|
||||
cancel=threading.Event();timer=threading.Timer(.3,cancel.set);timer.start()
|
||||
try:
|
||||
gateway.move_joints(arm,[.01]*7,.15,cancel)
|
||||
raise AssertionError('expected action cancellation')
|
||||
except Cancelled: pass
|
||||
timer.join()
|
||||
assert not gateway._goals
|
||||
joint_stop=threading.Event()
|
||||
def joint_feedback():
|
||||
while not joint_stop.wait(.03): gateway._joint(msg)
|
||||
joint_thread=threading.Thread(target=joint_feedback);joint_thread.start()
|
||||
try:
|
||||
assert gateway.stop_arm(arm)['ok']
|
||||
assert gateway.confirm_stopped(arm)['ok']
|
||||
assert controller_state[0]=='inactive'
|
||||
assert switches[-1]==([],['rizon_arm_controller'])
|
||||
# Only the next explicit command restores the controller.
|
||||
gateway.require_arm(arm['id'],threading.Event())
|
||||
assert controller_state[0]=='active'
|
||||
assert switches[-1]==(['rizon_arm_controller'],[])
|
||||
wrong={**arm,'sn':'OtherRobot'}
|
||||
try:
|
||||
gateway.stop_arm(wrong)
|
||||
raise AssertionError('wrong-arm stop accepted')
|
||||
except RuntimeError as exc: assert '不属于' in str(exc)
|
||||
finally:
|
||||
joint_stop.set();joint_thread.join()
|
||||
print('ROS stop deactivation, stationary confirmation, explicit resume and arm ownership PASS',flush=True)
|
||||
from arm_msgs.msg import TactileStatus
|
||||
from station import site_catalog
|
||||
force=TactileStatus();force.connected=True;force.fnormal=1.25;force.fshearx=-.5;force.fsheary=.75
|
||||
gateway._tactile('left',force)
|
||||
assert site_catalog._TACTILE['left']['fnormal']==1.25
|
||||
assert site_catalog._TACTILE['left']['fshearx']==-.5
|
||||
gateway.watch_tactile(True)
|
||||
gateway._tactile_image('left_thermal',message)
|
||||
assert 'left_thermal' in gateway.tactile_frames()
|
||||
gateway.watch_tactile(False)
|
||||
assert not gateway.tactile_frames() and not gateway._tactile_images.items
|
||||
print('ROS tactile driver reuse, unchanged force values and preview lifetime PASS',flush=True)
|
||||
print('ROS persistent gateway, arm identity and trajectory cancellation PASS',flush=True)
|
||||
finally:
|
||||
action.destroy();gateway.executor.remove_node(fake);fake.destroy_node();gateway.close()
|
||||
|
||||
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from station.rpc import call
|
||||
|
||||
|
||||
class BackendTest(TestCase):
|
||||
def test_offline_rpc_task_lifecycle_and_shutdown(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
socket=Path(folder)/'backend.sock'
|
||||
proc=subprocess.Popen([sys.executable,'-m','station.control_service','--socket',str(socket),'--offline'],
|
||||
stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True)
|
||||
try:
|
||||
deadline=time.monotonic()+10
|
||||
while not socket.exists() and proc.poll() is None and time.monotonic()<deadline: time.sleep(.05)
|
||||
self.assertIsNone(proc.poll())
|
||||
self.assertEqual(call(socket,'ping')['protocol'],1)
|
||||
catalog=call(socket,'catalog')
|
||||
unavailable={r['id'] for r in catalog['actions'] if not r['available']}
|
||||
self.assertIn('gauge_run',unavailable)
|
||||
with self.assertRaises(RuntimeError): call(socket,'submit',{'action':'__import__','arm':'89'})
|
||||
arms=[]
|
||||
while time.monotonic()<deadline:
|
||||
snap=call(socket,'snapshot');arms=snap.get('arms',[])
|
||||
if arms: break
|
||||
time.sleep(.05)
|
||||
self.assertTrue(arms)
|
||||
aid=arms[0]['id']
|
||||
token=call(socket,'submit',{'action':'handeye_show','arm':aid})['task_id']
|
||||
while time.monotonic()<deadline:
|
||||
row=call(socket,'task',{'task_id':token})
|
||||
if row['state'] not in ('queued','running'): break
|
||||
time.sleep(.05)
|
||||
self.assertEqual(row['state'],'succeeded')
|
||||
token=call(socket,'submit',{'action':'gripper_up','arm':aid})['task_id']
|
||||
while time.monotonic()<deadline:
|
||||
row=call(socket,'task',{'task_id':token})
|
||||
if row['state'] not in ('queued','running'): break
|
||||
time.sleep(.05)
|
||||
self.assertEqual(row['state'],'failed');self.assertIn('离线',row['result']['message'])
|
||||
finally:
|
||||
proc.terminate()
|
||||
try: output,_=proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill();output,_=proc.communicate();self.fail(output)
|
||||
self.assertEqual(proc.returncode,0,output)
|
||||
self.assertFalse(socket.exists())
|
||||
@@ -0,0 +1,19 @@
|
||||
import threading
|
||||
import time
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
from station.runtime_client import RuntimeClient
|
||||
|
||||
|
||||
class ClientStateTest(TestCase):
|
||||
def test_paint_snapshot_is_local_and_stale_state_is_not_live(self):
|
||||
client=RuntimeClient.__new__(RuntimeClient)
|
||||
client._lock=threading.RLock();client.error=''
|
||||
client._snapshot={'now':time.time()-10,'arm_live':{'89':{'enabled':True,'pose_live':True}},
|
||||
'gripper_live':{'89':{'connected':True,'pose_live':True}}}
|
||||
with patch('station.rpc.call',side_effect=AssertionError('paint path must not perform RPC')):
|
||||
snapshot=client.snapshot()
|
||||
self.assertFalse(snapshot['arm_live']['89']['pose_live'])
|
||||
self.assertIsNone(snapshot['arm_live']['89']['enabled'])
|
||||
self.assertFalse(snapshot['gripper_live']['89']['connected'])
|
||||
self.assertTrue(client._snapshot['gripper_live']['89']['connected'])
|
||||
@@ -0,0 +1,51 @@
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from unittest import TestCase
|
||||
from station.control_service import StationService
|
||||
|
||||
|
||||
class Gateway:
|
||||
active_arm='89'
|
||||
def __init__(self): self.started=None;self.requests=[]
|
||||
def graph(self): return {'nodes':[],'topics':[],'services':['/station/grippers/arm_89/execute']}
|
||||
def states(self):
|
||||
elapsed=time.monotonic()-self.started if self.started else 0
|
||||
return {},{'89':{'connected':True,'moving':bool(self.started and elapsed<.25),
|
||||
'grip_state':0 if self.started and elapsed<.25 else 1,'permille':500.}}
|
||||
def call(self,typ,name,request,event,timeout):
|
||||
self.requests.append(request);self.started=time.monotonic()
|
||||
return type('Reply',(),{'success':True,'message':'accepted'})()
|
||||
def close(self): pass
|
||||
|
||||
|
||||
class GripperCommandsTest(TestCase):
|
||||
def test_waits_for_physical_completion_and_rejects_bounds(self):
|
||||
gateway=Gateway()
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
service=StationService(root=root,gateway=gateway)
|
||||
try:
|
||||
arm=service.arms['89'];start=time.monotonic()
|
||||
result=service._gripper('gripper_tune',arm,{'kind':'pos','pos':500,'force':33,'speed':25},threading.Event())
|
||||
self.assertTrue(result['ok']);self.assertGreater(time.monotonic()-start,.2)
|
||||
self.assertEqual(gateway.requests[0].command.position,arm['stroke_mm']/2)
|
||||
count=len(gateway.requests)
|
||||
with self.assertRaises(ValueError):
|
||||
service._gripper('gripper_tune',arm,{'kind':'pos','pos':500,'force':999,'speed':25},threading.Event())
|
||||
self.assertEqual(len(gateway.requests),count)
|
||||
finally: service.close()
|
||||
|
||||
def test_configuration_changes_never_retarget_commands(self):
|
||||
from pathlib import Path
|
||||
gateway=Gateway()
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
service=StationService(root=root,gateway=gateway)
|
||||
try:
|
||||
path=Path(root)/'site.yaml';path.write_text('robot: before')
|
||||
service.config_path=path;service._config_signature=service._signature()
|
||||
path.write_text('robot: different-device')
|
||||
with self.assertRaisesRegex(RuntimeError,'配置已变化'):
|
||||
service.submit('gripper_probe','89')
|
||||
self.assertEqual(gateway.requests,[])
|
||||
self.assertTrue(service._frozen_config.is_file())
|
||||
finally: service.close()
|
||||
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import TestCase, skipUnless
|
||||
|
||||
|
||||
class GuiTest(TestCase):
|
||||
@skipUnless(os.environ.get('DISPLAY'), 'requires X11 for OpenGL widgets')
|
||||
def test_gui_isolated_from_hardware_and_closes_cleanly(self):
|
||||
env={**os.environ,'QT_QPA_PLATFORM':'xcb','ROS_DOMAIN_ID':'231','ROS_LOCALHOST_ONLY':'1'}
|
||||
with tempfile.TemporaryDirectory() as cache:
|
||||
env['XDG_CACHE_HOME']=cache
|
||||
result=subprocess.run([sys.executable,str(Path(__file__).with_name('gui_fixture.py'))],env=env,
|
||||
capture_output=True,text=True,timeout=20)
|
||||
self.assertEqual(result.returncode,0,result.stdout+'\n'+result.stderr)
|
||||
self.assertIn('shutdown PASS',result.stdout)
|
||||
@@ -0,0 +1,16 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
import numpy as np
|
||||
from station.image_preview import jpeg_preview
|
||||
|
||||
|
||||
class ImagePreviewTest(TestCase):
|
||||
def test_padding_vector_and_invalid_buffers(self):
|
||||
rgb=SimpleNamespace(encoding='rgb8',width=2,height=2,step=8,is_bigendian=False,data=(bytes([90,10,30])*2+b'xx')*2)
|
||||
self.assertTrue(jpeg_preview(rgb).startswith(b'\xff\xd8'))
|
||||
field=np.ones((4,4,2),np.float32)
|
||||
msg=SimpleNamespace(encoding='32FC2',width=4,height=4,step=32,is_bigendian=False,data=field.tobytes())
|
||||
self.assertTrue(jpeg_preview(msg).startswith(b'\xff\xd8'))
|
||||
field[0,0,0]=np.nan;msg.data=field.tobytes()
|
||||
self.assertIsNone(jpeg_preview(msg))
|
||||
rgb.data=b'bad';self.assertIsNone(jpeg_preview(rgb))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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): self.calls.append(('require',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)
|
||||
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,[('require','89')])
|
||||
|
||||
def test_detection_and_alignment_are_separate_stages(self):
|
||||
commands=[]
|
||||
def run(key, argv, event, env, cwd):
|
||||
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('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('no_descent:=true',commands[1]);self.assertIn('skip_stage3:=true',commands[1])
|
||||
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,[])
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
|
||||
|
||||
class ParentExitTest(TestCase):
|
||||
def test_backend_shuts_down_when_ui_owner_exits(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'service.sock'
|
||||
source='''import os,subprocess,sys,time
|
||||
p=subprocess.Popen([sys.executable,'-m','station.control_service','--socket',sys.argv[1],'--offline','--parent-pid',str(os.getpid())],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
print(p.pid,flush=True)
|
||||
end=time.monotonic()+8
|
||||
while not os.path.exists(sys.argv[1]) and p.poll() is None and time.monotonic()<end:time.sleep(.02)
|
||||
assert os.path.exists(sys.argv[1])
|
||||
'''
|
||||
parent=subprocess.run([sys.executable,'-c',source,str(path)],capture_output=True,text=True,timeout=12)
|
||||
self.assertEqual(parent.returncode,0,parent.stderr)
|
||||
end=time.monotonic()+8
|
||||
while path.exists() and time.monotonic()<end: time.sleep(.1)
|
||||
if path.exists():
|
||||
os.kill(int(parent.stdout.strip()),15)
|
||||
self.fail('orphan backend did not stop')
|
||||
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
from station import operator_teach as points
|
||||
|
||||
|
||||
class PointsTest(TestCase):
|
||||
def test_atomic_save_read_delete(self):
|
||||
with TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'points.yaml'
|
||||
with patch.object(points,'_file',return_value=path):
|
||||
points._write('89',[{'name':'p1','utc':'test','joints':[.1]*7}])
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertFalse(path.with_suffix('.yaml.tmp').exists())
|
||||
self.assertEqual(points.list_points('89')['points'][0]['name'],'p1')
|
||||
self.assertTrue(points.delete_point('89','p1')['ok'])
|
||||
self.assertEqual(points.list_points('89')['points'],[])
|
||||
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
from station import operator_camera_view as preview
|
||||
|
||||
|
||||
class PreviewCacheTest(TestCase):
|
||||
def tearDown(self):
|
||||
preview.watch(set(),set())
|
||||
preview._PREVIEW.clear();preview._META.clear()
|
||||
|
||||
def test_switch_and_stale_worker_clear_frames(self):
|
||||
preview.watch({'left_color'},set())
|
||||
preview._PREVIEW['left_color']=(b'123',1,1)
|
||||
preview.watch({'right_color'},set())
|
||||
self.assertNotIn('left_color',preview.previews())
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
preview._PREVIEW['right_color']=(b'123',1,1)
|
||||
payload={'frames':{},'exported_monotonic':time.monotonic()-10,'meta':{},'ages':{}}
|
||||
(Path(folder)/'meta.json').write_text(json.dumps(payload))
|
||||
with patch.object(preview,'_BRIDGE_DIR',Path(folder)):
|
||||
preview._ingest()
|
||||
self.assertEqual(preview.previews(),{})
|
||||
self.assertIn('未更新',preview.state()['error'])
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from arm_application.process_lifecycle import stop_process_tree,_stat
|
||||
|
||||
|
||||
class ProcessTreeTest(TestCase):
|
||||
def test_descendants_in_other_sessions_exit_without_touching_unrelated_process(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
script=Path(directory)/'child.py'
|
||||
script.write_text("import subprocess,sys,time,os\nfrom pathlib import Path\np=subprocess.Popen([sys.executable,'-c','import time;time.sleep(120)'],start_new_session=True)\nPath(sys.argv[1]).write_text(str(p.pid))\ntime.sleep(120)\n")
|
||||
pidfile=Path(directory)/'pid'
|
||||
unrelated=subprocess.Popen([sys.executable,'-c','import time;time.sleep(120)'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
root=subprocess.Popen([sys.executable,str(script),str(pidfile)],start_new_session=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
deadline=time.monotonic()+3
|
||||
while not pidfile.exists() and time.monotonic()<deadline:time.sleep(.02)
|
||||
self.assertTrue(pidfile.exists());child=int(pidfile.read_text())
|
||||
self.assertNotEqual(os.getpgid(child),os.getpgid(root.pid))
|
||||
stop_process_tree(root,interrupt_timeout=1,terminate_timeout=1)
|
||||
self.assertIsNotNone(root.poll())
|
||||
state=_stat(child)
|
||||
self.assertTrue(state is None or state[2]=='Z')
|
||||
self.assertIsNone(unrelated.poll())
|
||||
finally:
|
||||
if root.poll() is None:stop_process_tree(root,interrupt_timeout=.2,terminate_timeout=.2)
|
||||
unrelated.terminate();unrelated.wait(timeout=2)
|
||||
@@ -0,0 +1,21 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from unittest import TestCase
|
||||
from station.processes import ProcessSupervisor
|
||||
from station.tasks import Cancelled
|
||||
|
||||
|
||||
class ProcessesTest(TestCase):
|
||||
def test_owned_process_cancel_and_exit(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
processes=ProcessSupervisor(directory)
|
||||
try:
|
||||
result=processes.run('echo',[sys.executable,'-c','print("completed")'],threading.Event())
|
||||
self.assertTrue(result['ok']);self.assertIn('completed',result['detail'])
|
||||
event=threading.Event();timer=threading.Timer(.2,event.set);timer.start()
|
||||
with self.assertRaises(Cancelled):
|
||||
processes.run('long',[sys.executable,'-c','import time;time.sleep(30)'],event)
|
||||
self.assertFalse(processes.snapshot()['long']['running'])
|
||||
finally: processes.close()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""工位 Qt 运行时回归:无设备、无 ROS,检测导致一分钟崩溃的引用计数错误。
|
||||
|
||||
运行:bash station/run_station.sh --self-test
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import PySide6
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
|
||||
class QtRuntimeTest(unittest.TestCase):
|
||||
def test_void_calls_preserve_none(self):
|
||||
# 先检查版本,防止在有缺陷的绑定上运行压力循环导致解释器直接 abort。
|
||||
self.assertEqual(PySide6.__version__, "6.8.3")
|
||||
app = QApplication.instance() or QApplication([])
|
||||
label = QLabel("warmup")
|
||||
|
||||
def refresh():
|
||||
label.setText("updated")
|
||||
label.update()
|
||||
label.setVisible(False)
|
||||
label.style().unpolish(label)
|
||||
label.style().polish(label)
|
||||
|
||||
for _ in range(20):
|
||||
refresh()
|
||||
app.processEvents()
|
||||
before = sys.getrefcount(None)
|
||||
for _ in range(4000):
|
||||
refresh()
|
||||
after = sys.getrefcount(None)
|
||||
self.assertGreater(after - before, -50, "Qt 调用持续递减 None 引用计数")
|
||||
label.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,14 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
|
||||
|
||||
class RosInterfacesTest(TestCase):
|
||||
def test_fake_driver_on_private_dds_domain(self):
|
||||
env={**os.environ,'ROS_DOMAIN_ID':'231','ROS_LOCALHOST_ONLY':'1'}
|
||||
result=subprocess.run([sys.executable,str(Path(__file__).with_name('ros_fixture.py'))],env=env,
|
||||
capture_output=True,text=True,timeout=25)
|
||||
self.assertEqual(result.returncode,0,result.stdout+'\n'+result.stderr)
|
||||
self.assertIn('dynamic camera subscriptions PASS',result.stdout)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""配置缓存必须保留热更新和调用者之间的数据隔离。"""
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from station import site_catalog as console
|
||||
|
||||
|
||||
class SiteConfigTest(TestCase):
|
||||
def test_edit_and_delete_config(self):
|
||||
with TemporaryDirectory() as folder, patch.object(console, "ROOT", Path(folder)):
|
||||
path = Path(folder) / "config" / "site.yaml"
|
||||
path.parent.mkdir()
|
||||
self.assertEqual(console._site(), {})
|
||||
path.write_text("robot:\n sn: original\n")
|
||||
first = console._site()
|
||||
first["robot"]["sn"] = "local change"
|
||||
self.assertEqual(console._site()["robot"]["sn"], "original")
|
||||
path.write_text("robot:\n sn: updated-robot\n")
|
||||
self.assertEqual(console._site()["robot"]["sn"], "updated-robot")
|
||||
path.unlink()
|
||||
self.assertEqual(console._site(), {})
|
||||
@@ -0,0 +1,22 @@
|
||||
from unittest import TestCase
|
||||
from station.subscriptions import SubscriptionSet, demanded_streams
|
||||
|
||||
|
||||
class SubscriptionsTest(TestCase):
|
||||
def test_switches_release_unused_real_sim_depth(self):
|
||||
cams=[{'id':'left'},{'id':'right'},{'id':'sim_wrist'},{'id':'overhead'}]
|
||||
subscriptions=SubscriptionSet();created=[];destroyed=[]
|
||||
def create(key): created.append(key);return key
|
||||
def sync(preview=(),jpeg=()):
|
||||
subscriptions.sync(demanded_streams(cams,preview,jpeg),create,destroyed.append)
|
||||
sync(['left_color','overhead_color'])
|
||||
self.assertEqual(set(created),{'left_color','overhead_color'})
|
||||
sync(['right_depth','overhead_depth'])
|
||||
self.assertEqual(set(subscriptions.items),{'right_depth','overhead_depth'})
|
||||
self.assertEqual(set(destroyed),{'left_color','overhead_color'})
|
||||
sync([],['left_color'])
|
||||
self.assertEqual(set(subscriptions.items),{'left_color'})
|
||||
sync(['sim_wrist_color'])
|
||||
self.assertEqual(set(subscriptions.items),{'sim_wrist_color'})
|
||||
sync();self.assertEqual(subscriptions.items,{})
|
||||
sync(['unknown_color']);self.assertEqual(subscriptions.items,{})
|
||||
@@ -0,0 +1,35 @@
|
||||
import threading
|
||||
import time
|
||||
from unittest import TestCase
|
||||
from station.tasks import TaskManager
|
||||
|
||||
|
||||
class TasksTest(TestCase):
|
||||
def setUp(self): self.manager=TaskManager()
|
||||
def tearDown(self): self.manager.close()
|
||||
def wait(self,token):
|
||||
until=time.monotonic()+3
|
||||
while time.monotonic()<until:
|
||||
row=self.manager.snapshot(token)
|
||||
if row['state'] not in ('running','queued'): return row
|
||||
time.sleep(.01)
|
||||
self.fail('task did not complete')
|
||||
|
||||
def test_resources_held_until_completion(self):
|
||||
gate=threading.Event()
|
||||
one=self.manager.submit('move',{'arm:89'},lambda cancel:(gate.wait(2),{'ok':True})[1])
|
||||
with self.assertRaises(RuntimeError): self.manager.submit('move-again',{'arm:89'},lambda e:{'ok':True})
|
||||
other=self.manager.submit('camera',{'camera'},lambda e:{'ok':True})
|
||||
self.assertEqual(self.wait(other)['state'],'succeeded')
|
||||
gate.set();self.assertEqual(self.wait(one)['state'],'succeeded')
|
||||
next_task=self.manager.submit('next',{'arm:89'},lambda e:{'ok':True})
|
||||
self.assertEqual(self.wait(next_task)['state'],'succeeded')
|
||||
|
||||
def test_cancel_and_exception_release_ownership(self):
|
||||
token=self.manager.submit('long',{'arm'},lambda event:(event.wait(2),{'ok':False})[1])
|
||||
self.manager.cancel(token)
|
||||
self.assertEqual(self.wait(token)['state'],'cancelled')
|
||||
def fail(event): raise ValueError('driver rejected')
|
||||
token=self.manager.submit('reject',{'arm'},fail)
|
||||
result=self.wait(token)
|
||||
self.assertEqual(result['state'],'failed');self.assertIn('driver rejected',result['result']['message'])
|
||||
Reference in New Issue
Block a user