Initial import of ORISYS arm workstation

This commit is contained in:
cheng
2026-10-16 17:27:30 +08:00
commit 2b74c50ba8
745 changed files with 1199101 additions and 0 deletions
+67
View File
@@ -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)
+184
View File
@@ -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()
+51
View File
@@ -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())
+19
View File
@@ -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'])
+51
View File
@@ -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()
+18
View File
@@ -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)
+16
View File
@@ -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))
+74
View File
@@ -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,[])
+27
View File
@@ -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')
+18
View File
@@ -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'],[])
+27
View File
@@ -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'])
+31
View File
@@ -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)
+21
View File
@@ -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()
+39
View File
@@ -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()
+14
View File
@@ -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)
+23
View File
@@ -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(), {})
+22
View File
@@ -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,{})
+35
View File
@@ -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'])