feat: 新增 SerialMate 串口调试助手 v0.1.0
- 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""GUI 功能自检(offscreen 无窗口): 组帧预览/TX回显/帧日志/通道提取/显示切换/
|
||||
未连接丢弃/配置往返
|
||||
====================================================================
|
||||
不依赖硬件与串口, 直接驱动 Window 的事件处理函数; 改 main.py 后建议重跑。
|
||||
|
||||
用法: python selftest_gui.py (全部通过退出码 0)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import config
|
||||
import main as m
|
||||
import protocol as d
|
||||
from plot import fmt_val
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, got, want):
|
||||
ok = got == want
|
||||
print(('PASS' if ok else 'FAIL'), name, '->', repr(got)[:120],
|
||||
'' if ok else f'(期望 {want!r})')
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# 0) 数值显示格式: 普通小数千分位(科学计数只留给 10 亿以上)
|
||||
check('fmt_val 千分位', fmt_val(28210029), '28,210,029')
|
||||
check('fmt_val 小数', fmt_val(24.6), '24.6')
|
||||
check('fmt_val 负小数', fmt_val(-0.25), '-0.25')
|
||||
check('fmt_val 零', fmt_val(0), '0')
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = m.Window()
|
||||
win.show() # offscreen 显示, isVisible 类断言前提
|
||||
check('倍率/偏移/序号框宽度(防数值裁切回归)',
|
||||
win._ch_ui[0]['scale'].width() >= 130
|
||||
and win._ch_ui[0]['off'].width() >= 110
|
||||
and win._ch_ui[0]['idx'].width() >= 110, True)
|
||||
win._apply_minimum_size()
|
||||
check('窗口最小高度(防曲线卡叠压)', win.minimumHeight() >= 800, True)
|
||||
|
||||
# 0b) 曲线时间窗换算(纯计算; 窗口不向数据收敛——选 5min 就显示 5min 宽的轴)
|
||||
p = win.plot
|
||||
p.set_view(10)
|
||||
p.set_offset(0)
|
||||
check('时间窗跟随右缘', p.visible_domain(0, 100) == (90.0, 100.0), True)
|
||||
p.set_offset(30)
|
||||
check('时间窗滞后回看', p.visible_domain(0, 100) == (60.0, 70.0), True)
|
||||
p.set_offset(90)
|
||||
check('时间窗滑到最旧', p.visible_domain(0, 100) == (0.0, 10.0), True)
|
||||
p.set_offset(500)
|
||||
check('滞后超史为空窗(保宽)', p.visible_domain(0, 100) == (-410.0, -400.0), True)
|
||||
p.set_view(300)
|
||||
p.set_offset(0)
|
||||
check('数据不足窗宽不收敛', p.visible_domain(0, 23) == (-277.0, 23.0), True)
|
||||
p.set_view(None)
|
||||
p.set_offset(0)
|
||||
check('跟随全部', p.visible_domain(0, 100) == (0.0, 100.0), True)
|
||||
|
||||
# 0c) 悬停读值: 最近采样命中/容差外未命中
|
||||
import time as _time
|
||||
base = _time.perf_counter()
|
||||
p._times[0].clear()
|
||||
p._vals[0].clear()
|
||||
p._names[0] = '甲'
|
||||
for tt, vv in ((0.0, 10.0), (1.0, 20.0), (2.0, 30.0)):
|
||||
p._times[0].append(base + tt)
|
||||
p._vals[0].append(vv)
|
||||
check('悬停命中最近点', p._nearest(0, base + 1.9, 0.2) == ('甲', 30.0), True)
|
||||
check('悬停容差外未命中', p._nearest(0, base + 1.4, 0.2) is None, True)
|
||||
lo, hi = p.auto_yrange()
|
||||
check('自动Y量程', abs(lo - 8.4) < 1e-9 and abs(hi - 31.6) < 1e-9, True)
|
||||
from PySide6.QtCore import QPoint
|
||||
p.resize(600, 300) # 未走布局, 手动给几何
|
||||
lines = p._hover_lines_at(QPoint(320, 150)) # x=绘图区正中 → t=base+1.0
|
||||
check('悬停框内容(位置固定也随数据刷新)',
|
||||
lines[:2] == [f'T = {base + 1.0:.2f}s', '甲 = 20'], True)
|
||||
|
||||
# 0d) 暂停渲染 vs 暂停采集: 冻结期间继续收数, 解冻后数据都在
|
||||
p.set_frozen(True)
|
||||
n0 = len(p._times[0])
|
||||
p.add(0, 42.0)
|
||||
check('冻结期间继续收数', p.is_frozen() and len(p._times[0]) == n0 + 1, True)
|
||||
p.set_frozen(False)
|
||||
p.set_paused(True)
|
||||
n1 = len(p._times[0])
|
||||
p.add(0, 43.0)
|
||||
check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True)
|
||||
p.set_paused(False)
|
||||
|
||||
# 1) 组帧预览: 多条 '/' 分隔
|
||||
win.ed_tx.setText('M1/M731S0T1000')
|
||||
check('多条预览', win.lb_preview.text(),
|
||||
'D5 01 02 4D 31 81 | D5 01 0B 4D 37 33 31 53 30 54 31 30 30 30 8C')
|
||||
win.combo_tx_mode.setCurrentIndex(1)
|
||||
win.ed_tx.setText('D5 01 02 4D 31 81')
|
||||
check('HEX预览', win.lb_preview.text(), '→ D5 01 02 4D 31 81')
|
||||
win.combo_tx_mode.setCurrentIndex(0)
|
||||
win.ed_tx.setText('M1')
|
||||
|
||||
# 1b) 帧构成定制: 无板ID布局 + LEN 口径切换 + 非法输入提示
|
||||
win.ed_layout.setText('HDR LEN PAYLOAD CRC')
|
||||
check('帧构成无ID预览', win.lb_preview.text(), 'D5 02 4D 31 80')
|
||||
win.combo_len_sem.setCurrentIndex(1) # 到帧尾(payload+CRC)
|
||||
check('帧构成LEN口径预览', win.lb_preview.text(), 'D5 03 4D 31 81')
|
||||
win.combo_len_sem.setCurrentIndex(0)
|
||||
win.ed_layout.setText('HDR LEN') # 缺 PAYLOAD/CRC -> 拒绝
|
||||
check('帧构成非法提示', win.lb_preview.text().startswith('⚠'), True)
|
||||
check('帧构成非法详情', 'PAYLOAD 必须恰好出现一次' in win.lb_proto.text(), True)
|
||||
win.ed_layout.setText(d.DEFAULT_LAYOUT)
|
||||
check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81')
|
||||
|
||||
# 2) TX 回显行
|
||||
cfg = win._proto_from_ui()
|
||||
win._on_tx('M1', cfg.build_frame(b'M1'))
|
||||
check('TX日志行', 'M1 [D5 01 02 4D 31 81]' in win.text_rx.toPlainText(), True)
|
||||
|
||||
# 3) 帧事件: 日志 + 通道提取(文本数字×0.1)
|
||||
win.combo_display.setCurrentIndex(0)
|
||||
fr = d.FrameParser(d.ProtoConfig()).feed(
|
||||
bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48'))[0]
|
||||
win._ch_ui[0]['chk'].setChecked(True)
|
||||
win._ch_ui[0]['mode'].setCurrentIndex(1) # 文本数字
|
||||
win._ch_ui[0]['idx'].setValue(1)
|
||||
win._ch_ui[0]['scale'].setValue(0.1)
|
||||
win._on_frame(fr)
|
||||
last = win.text_rx.toPlainText().splitlines()[-1]
|
||||
check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last
|
||||
and 'okTL:246' in last, True)
|
||||
check('通道最新值', win._ch_ui[0]['val'].text(), '24.6')
|
||||
|
||||
# 4) HEX 显示切换 + 原始帧
|
||||
win.combo_display.setCurrentIndex(1)
|
||||
win._on_frame(fr)
|
||||
last = win.text_rx.toPlainText().splitlines()[-1]
|
||||
check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last
|
||||
and '[B5 F0 08 6F 6B 54 4C 3A 32 34 36 48]' in last, True)
|
||||
|
||||
# 5) 透传行
|
||||
win.combo_display.setCurrentIndex(1)
|
||||
win._on_raw(b'\x11\x22')
|
||||
check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True)
|
||||
|
||||
# 6) 未连接发送丢弃
|
||||
win._send()
|
||||
win._drain_events()
|
||||
check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True)
|
||||
|
||||
# 7) 配置往返
|
||||
store = win._store_from_ui()
|
||||
check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']),
|
||||
('D5', 'SUM8'))
|
||||
check('配置帧构成', (store['proto']['layout'], store['proto']['len_sem']),
|
||||
(d.DEFAULT_LAYOUT, 'payload'))
|
||||
check('配置通道1', (store['channels'][0]['enabled'], store['channels'][0]['scale']),
|
||||
(True, 0.1))
|
||||
|
||||
# 8) 板型档案: 保存/切换应用协议参数/存入命令/清理
|
||||
check('板型档案加载', win.combo_profile.count() >= 1, True)
|
||||
win.ed_board.setText('03')
|
||||
check('存为板型', win._save_profile_as('测试板'), True)
|
||||
names = [win.combo_profile.itemText(i) for i in range(win.combo_profile.count())]
|
||||
check('板型入列', '测试板' in names, True)
|
||||
pidx = names.index('测试板')
|
||||
win.combo_profile.setCurrentIndex(pidx)
|
||||
check('切换板型应用板ID', win.ed_board.text(), '03')
|
||||
win.combo_profile.setCurrentIndex(0)
|
||||
check('切回默认板ID', win.ed_board.text(), '01')
|
||||
win.ed_tx.setText('M1')
|
||||
win._add_command_named('M1', 'M1')
|
||||
check('存入命令去重', any(c[1] == 'M1' for c in win._commands)
|
||||
and len([c for c in win._commands if c[1] == 'M1']) == 1, True)
|
||||
check('预设显示名命令', win.combo_preset.itemText(0) == '握手 · M1', True)
|
||||
# 10) 新建板型(命令预设从空白开始) + 命令预设表编辑器
|
||||
check('新建板型', win._new_profile_named('新建测试板'), True)
|
||||
check('重名新建拒绝', win._new_profile_named('新建测试板'), False)
|
||||
check('新建板型命令为空', win._commands == [], True)
|
||||
dlg = m.ProfileEditDialog(win.profiles[win.combo_profile.currentIndex()])
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
for r, (n, c) in enumerate((('甲', 'A1'), ('乙', 'B2'), ('丙', 'C3'))):
|
||||
dlg.table._add_row()
|
||||
dlg.table.setItem(r, 1, QTableWidgetItem(n))
|
||||
dlg.table.setItem(r, 2, QTableWidgetItem(c))
|
||||
check('编辑器收集', dlg.commands() == [['甲', 'A1'], ['乙', 'B2'], ['丙', 'C3']],
|
||||
True)
|
||||
dlg.table.move_row(0, 2) # 拖动排序: 首行挪到末尾
|
||||
check('拖动换序', dlg.commands() == [['乙', 'B2'], ['丙', 'C3'], ['甲', 'A1']],
|
||||
True)
|
||||
check('手柄列存在', dlg.table.item(0, 0).text() == '≡', True)
|
||||
dlg.table._add_row() # 重复命令与空命令行均被忽略
|
||||
dlg.table.setItem(3, 1, QTableWidgetItem('重复'))
|
||||
dlg.table.setItem(3, 2, QTableWidgetItem('B2'))
|
||||
check('编辑器去重空行', dlg.commands() == [['乙', 'B2'], ['丙', 'C3'], ['甲', 'A1']],
|
||||
True)
|
||||
# 11) 删除板型: 核函数删除/不存在拒绝/至少保留一个
|
||||
check('删除板型', win._delete_profile('新建测试板'), True)
|
||||
check('删除后不在列', all(p['name'] != '新建测试板' for p in win.profiles), True)
|
||||
check('删除不存在拒绝', win._delete_profile('不存在的板型'), False)
|
||||
# 清理运行态测试板型
|
||||
win.profiles[:] = [p for p in win.profiles if p['name'] != '测试板']
|
||||
config.save_profiles(win.profiles)
|
||||
win._reload_profiles()
|
||||
check('清理测试板型', all(p['name'] != '测试板' for p in win.profiles), True)
|
||||
|
||||
# 9) 曲线弹出/嵌回
|
||||
win._toggle_plot_window()
|
||||
check('曲线弹出', win._plot_win is not None
|
||||
and win.plot.window() is win._plot_win, True)
|
||||
win._toggle_plot_window()
|
||||
check('曲线嵌回', win._plot_win is None and win.plot.parent() is not None, True)
|
||||
win._pop_plot()
|
||||
win._dock_plot()
|
||||
check('弹出后关窗嵌回', win._plot_win is None and win.plot.isVisible(), True)
|
||||
|
||||
win.link.close()
|
||||
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in New Issue
Block a user