Files
linkerhand_retarget_ros2/src/linkerhand_range_calibration/test/test_gui.py
T
admin af2dc9c38f 新增 O30 右手行程标定功能包并完善相机恢复
支持三机位 Tag 两端边界标定、滑块示教、顺序避让恢复、同步四指扫描和 JSON 离线复算;修复相机时钟异常导致采集线程退出的问题,并忽略本地行程标定输出。
2026-09-17 12:41:53 +08:00

212 lines
9.8 KiB
Python

"""Exercise real Qt widgets against the ROS host with its fake adapter."""
import os
from pathlib import Path
import subprocess
import sys
import textwrap
def test_demo_gui_teach_scan_pause_resume_cancel(tmp_path):
program = r'''
import json,time,sys
from pathlib import Path
import rclpy
from PyQt5.QtCore import Qt
from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication,QLabel
from linkerhand_range_calibration.profiles import load_profile,load_station
from linkerhand_range_calibration.runtime import CalibrationRuntime
from linkerhand_range_calibration.ui.window import CalibrationWindow
rclpy.init();app=QApplication([])
station=load_station();station['output_root']=str(Path(sys.argv[1])/'output')
station['teaching_root']=str(Path(sys.argv[1])/'teaching')
station['scan'].update(rate=1000,stable_frames=4,settle_seconds=.01)
node=CalibrationRuntime(load_profile(),station,demo=True)
window=CalibrationWindow(node);window.show()
def until(condition,timeout=6):
end=time.monotonic()+timeout
while time.monotonic()<end:
rclpy.spin_once(node,timeout_sec=.015);app.processEvents();window.refresh()
if condition():return
raise AssertionError(str(node.snapshot()))
until(lambda:node.snapshot().get('can_save',False))
assert not node.adapter.sent and not node.adapter.settings_sent
assert len(node.previews())==3
assert len(window.joint_controls)==20
clearance_choices=[window.save_kind.itemData(i)[1] for i in range(window.save_kind.count())
if window.save_kind.itemData(i)[0]=='clearances']
assert clearance_choices==[]
first,second=window.joint_controls[:2]
assert first.slider.minimum()==0 and first.slider.maximum()==255
window.sync_targets();window.fill.click()
node._tick()
assert not node.adapter.sent and not node.adapter.settings_sent
# Many drag changes before one runtime tick keep only the latest target.
for value in range(20,101):first.slider.setValue(value)
assert not node.adapter.sent
node._tick();window.refresh()
assert node.engine.state=='TEACHING' and node.adapter.target[0]==100
assert first.isEnabled() and not window.save.isEnabled()
assert len(node.adapter.settings_sent)==1
second.slider.setValue(45)
first.slider.setValue(30)
node._tick()
assert node.adapter.target[:2]==(30,45)
assert first.editor.value()==30 and second.editor.value()==45
assert len(node.adapter.settings_sent)==1
until(lambda:node.engine.state=='IDLE')
assert node.adapter.target[:2]==(30,45)
assert node.adapter.target[2:]==(0,)*18
# Numeric typing waits for confirmation, and then drives the same slider path.
first.editor.setFocus();first.editor.selectAll()
QTest.keyClicks(first.editor,'35')
node._tick()
assert node.adapter.target[0]==30 and not node.pending_jog
QTest.keyClick(first.editor,Qt.Key_Return)
until(lambda:node.engine.state=='IDLE' and node.adapter.target[0]==35)
assert first.slider.value()==35
assert len(node.adapter.settings_sent)==1 # idle between gestures does not resend settings
# Finishing one joint must not overwrite another joint's unconfirmed numeric input.
second.slider.setValue(80);node._tick();window.refresh()
first.editor.setFocus();first.editor.selectAll()
QTest.keyClicks(first.editor,'36')
until(lambda:node.engine.state=='IDLE')
assert first.editor.text()=='36' and node.adapter.target[0]==35
QTest.keyClick(first.editor,Qt.Key_Escape)
window.sync_targets()
# Cancel discards a pending gesture and rejects late events from that UI epoch.
old_epoch=node.snapshot()['manual_epoch']
before=len(node.adapter.sent)
first.slider.setValue(200)
node.submit('cancel')
node.submit('jog',targets={node.profile.joints[0].name:240},epoch=old_epoch)
node._tick();window.refresh()
assert node.engine.state=='CANCELLED' and len(node.adapter.sent)==before
assert first.slider.value()==35
# Pause holds the last sent target, and manual controls remain locked until cancel.
first.slider.setValue(200);node._tick();window.refresh()
assert node.engine.state=='TEACHING'
window.pause.click();until(lambda:node.engine.state=='PAUSED')
assert not first.isEnabled() and not window.resume.isEnabled()
before=len(node.adapter.sent)
node.submit('jog',targets={node.profile.joints[0].name:255},epoch=node.snapshot()['manual_epoch'])
node._tick()
assert len(node.adapter.sent)==before
window.cancel.click();until(lambda:node.engine.state=='CANCELLED')
window.start_one.click()
until(lambda:node.engine.state=='SCANNING')
assert not first.isEnabled() and not window.fill.isEnabled()
window.pause.click();until(lambda:node.engine.state=='PAUSED')
window.resume.click();until(lambda:node.engine.state=='PREPARING')
window.cancel.click();until(lambda:node.engine.state=='CANCELLED')
document=json.loads(node.session.result_path.read_text())
assert len(document['joints'])==20 and all(v=={'min':None,'max':None} for v in document['joints'].values())
held=node.engine.command
first.slider.setValue(10)
until(lambda:node.engine.state=='IDLE' and node.adapter.target[0]==10)
assert node.adapter.target[1:]==held[1:]
until(lambda:node.snapshot()['can_save'])
window.save.click();until(lambda:node.teaching_path.exists())
assert node.teaching.data['baseline']['target'][0]==10
# Saving a nonzero baseline keeps the fixed thumb clearance at zero.
index_control=window.joint_controls[2]
index_control.slider.setValue(37)
until(lambda:node.snapshot()['can_save'] and node.adapter.target[2]==37)
window.save.click()
until(lambda:node.teaching.data['baseline']['target'][2]==37)
saved=json.loads(json.dumps(node.teaching.data))
assert saved['baseline']['target'][0]==10
assert not saved['clearances']
tasks={task.name:task for task in node.profile.tasks}
assert node.teaching.prepare(tasks['thumb_cmc_roll'])[2]==0
assert node.teaching.prepare(tasks['four_finger_roll'])[2]==37
# A command received during a tick must not expose its epoch before it is applied.
original_tick=node.engine.tick
def cancel_during_tick(now,stamp):
node.submit('cancel')
original_tick(now,stamp)
node.engine.tick=cancel_during_tick
node._tick()
node.engine.tick=original_tick
assert node.snapshot()['manual_epoch']<node.manual_epoch
node.submit('jog',targets={node.profile.joints[0].name:255},epoch=node.snapshot()['manual_epoch'])
node._tick()
assert node.engine.state=='CANCELLED' and not node.pending_jog
# SDK diagnostics remain in the journal and never add a banner or lock controls.
node.adapter.warning='thumb_cmc_roll:执行器层判定堵转;thumb_cmc_yaw:执行器过温'
node._tick();window.refresh()
assert window.start_one.isEnabled() and all(control.isEnabled() for control in window.joint_controls)
assert not any('自动标定不可用' in label.text() or '堵转' in label.text() or '过温' in label.text()
for label in window.findChildren(QLabel))
window.start_one.click();until(lambda:node.engine.state=='SCANNING')
events=[json.loads(line) for line in (node.session.directory/'samples.jsonl').read_text().splitlines()]
assert any(event['kind']=='diagnostic_warning' and '过温' in event['reason'] for event in events)
window.pause.click();until(lambda:node.engine.state=='PAUSED')
assert window.resume.isEnabled()
window.resume.click();until(lambda:node.engine.state=='PREPARING')
window.cancel.click();until(lambda:node.engine.state=='CANCELLED')
# Manual teaching uses the same readiness check, including the joint named in a diagnostic.
node._tick();window.refresh()
assert first.isEnabled() and index_control.isEnabled() and second.isEnabled()
assert window.start_one.isEnabled()
settings_with_warning=len(node.adapter.settings_sent)
second.slider.setValue(75)
index_control.slider.setValue(45)
until(lambda:node.snapshot()['can_save'] and node.adapter.target[2]==45 and node.adapter.target[1]==75)
assert node.engine.state=='IDLE'
assert window.save.isEnabled() and len(node.adapter.settings_sent)==settings_with_warning
window.save.click()
until(lambda:node.teaching.data['baseline']['target'][2]==45)
node.adapter.warning=None
node._tick();window.refresh()
assert second.isEnabled() and window.start_one.isEnabled()
# After a connection fault, the next deliberate gesture reapplies settings once.
settings_before=len(node.adapter.settings_sent)
node.adapter.error='模拟反馈断流'
node._tick();window.refresh()
assert not first.isEnabled() and node.motion_settings_key is None
node.adapter.error=None
node._tick();window.refresh()
first.slider.setValue(11)
node._tick()
assert node.adapter.target[0]==11
assert len(node.adapter.settings_sent)==settings_before+1
until(lambda:node.engine.state=='IDLE')
# A single thumb-roll task restores the thumb before releasing the index clearance.
window.start_one.click();until(lambda:node.engine.state=='RESTORING')
assert not any(control.isEnabled() for control in window.joint_controls)
assert not window.save.isEnabled() and window.cancel.isEnabled()
assert '测后恢复基础姿态' in window.status.text()
assert node.engine.restoring_joint=='thumb_cmc_roll'
assert 'thumb_cmc_roll 恢复基础姿态' in window.status.text()
assert node.engine.command[2]==0
until(lambda:node.engine.restoring_joint=='index_mcp_roll')
assert 'index_mcp_roll 恢复基础姿态' in window.status.text()
assert node.engine.command[0]==node.teaching.data['baseline']['target'][0]
assert not any(control.isEnabled() for control in window.joint_controls)
until(lambda:node.engine.state=='COMPLETED')
assert first.slider.value()==node.teaching.data['baseline']['target'][0]
assert index_control.slider.value()==node.teaching.data['baseline']['target'][2]
assert all(control.isEnabled() for control in window.joint_controls)
window.close();node.close_session();node.destroy_node();rclpy.shutdown()
print('GUI workflow passed')
'''
env = dict(os.environ, ROS_DOMAIN_ID='199', ROS_LOCALHOST_ONLY='1', QT_QPA_PLATFORM='offscreen')
env['PYTHONPATH'] = str(Path(__file__).resolve().parents[1])+os.pathsep+env.get('PYTHONPATH','')
result = subprocess.run([sys.executable,'-c',textwrap.dedent(program),str(tmp_path)],env=env,
capture_output=True,text=True,timeout=25)
assert result.returncode==0, result.stdout+result.stderr