264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""GUI 功能自检(offscreen 无窗口): 组帧预览/TX回显/帧日志/通道提取/显示切换/
|
||
未连接丢弃/配置往返
|
||
====================================================================
|
||
不依赖硬件与串口, 直接驱动 Window 的事件处理函数; 改 main.py 后建议重跑。
|
||
|
||
用法: python selftest_gui.py (全部通过退出码 0)
|
||
"""
|
||
import os
|
||
import re
|
||
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)
|
||
|
||
# 0e) 时间戳: 纯格式化函数, 传入到达时刻得确定字符串, 不传兜底当前时刻
|
||
t_fix = 1759474800 + 0.456
|
||
check('时间戳格式化(到达时刻)', m._timestamp(t_fix),
|
||
_time.strftime('%H:%M:%S', _time.localtime(t_fix)) + '.456')
|
||
check('时间戳格式化(现在兜底)',
|
||
re.match(r'\d{2}:\d{2}:\d{2}\.\d{3}$', m._timestamp()) is not None, True)
|
||
|
||
# 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 回显行(直接驱动处理函数, 断言前 flush 批量日志缓冲)
|
||
cfg = win._proto_from_ui()
|
||
win._on_tx('M1', cfg.build_frame(b'M1'))
|
||
win._flush_rx()
|
||
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)
|
||
win._flush_rx()
|
||
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')
|
||
# 时间戳用 link 后台线程打的到达时刻, 而非 GUI 消费时刻
|
||
ft = _time.time() - 3.456
|
||
win._on_frame(fr, ft)
|
||
win._flush_rx()
|
||
check('帧日志用到达时刻', m._timestamp(ft)
|
||
in win.text_rx.toPlainText().splitlines()[-1], True)
|
||
|
||
# 4) HEX 显示切换 + 原始帧
|
||
win.combo_display.setCurrentIndex(1)
|
||
win._on_frame(fr)
|
||
win._flush_rx()
|
||
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')
|
||
win._flush_rx()
|
||
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)
|
||
|
||
# 6b) 通道配置缓存: 帧路径读 _ch_cfg, 用户改配置时重建
|
||
win._ch_ui[1]['chk'].setChecked(True)
|
||
win._ch_ui[1]['name'].setText('乙')
|
||
check('通道缓存随配置重建', win._ch_cfg[1]['enabled']
|
||
and win._ch_cfg[1]['name'] == '乙', True)
|
||
win._ch_ui[1]['chk'].setChecked(False)
|
||
check('通道缓存关闭同步', not win._ch_cfg[1]['enabled'], True)
|
||
|
||
# 6c) 积压折叠: 队列积压超阈值时帧日志折叠为计数摘要(带首尾到达时刻), 曲线照常入库
|
||
rx_n0, plot_n0 = win._rx_n, len(p._vals[0])
|
||
now = _time.time()
|
||
for k in range(250): # 100ms 间隔的到达时刻, 模拟高频上报
|
||
win.events.put(('frame', fr, now - (250 - k) / 10.0))
|
||
win._drain_events()
|
||
log = win.text_rx.toPlainText()
|
||
check('积压折叠摘要行', '折叠 250 帧' in log, True)
|
||
check('折叠显示到达跨度', m._timestamp(now - 25.0) in log
|
||
and m._timestamp(now - 0.1) in log, True)
|
||
check('折叠保曲线', len(p._vals[0]) == plot_n0 + 250, True)
|
||
check('折叠计数器照常', win._rx_n == rx_n0 + 250, True)
|
||
check('折叠摘要含帧描述', 'B=0xF0 成功 ×250' in log, 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)
|