"""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)