#!/usr/bin/env python3 # -*- coding: utf-8 -*- """app_fluent 离屏回归(无硬件): 曲线渲染像素 + 帧派发状态机 + 主题往返 + CSV 用法: python selftest_gui.py (全部通过退出码 0; 需 PySide6-Fluent-Widgets) """ import math import os from collections import deque import sys import time os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import filmforge_rs485 as ff from PySide6.QtGui import QImage, QPainter from PySide6.QtWidgets import QApplication fails = [] def check(name, got, want): ok = got == want print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})') if not ok: fails.append(name) def mk_frame(payload, type_code=0xF0): body = bytes([type_code, len(payload)]) + payload.encode('ascii') return ff.FrameParser().feed(bytes([0xB5]) + body + bytes([ff.sum_crc(body)]))[0] app = QApplication([]) import app_fluent window = app_fluent.App(0) window.show() app.processEvents() # 1) 空数据渲染不抛异常(占位文案) img = QImage(window.chart.size(), QImage.Format_ARGB32) window.chart.render(img) check('空数据渲染', not img.isNull(), True) # 2) 注入合成数据后渲染: 实测曲线/目标线/故障标记像素均存在 t0 = time.time() - 120 for i in range(600): window.samples_t.append(t0 + i * 0.2) window.samples_v.append(24 + 5 * math.sin(i / 40) + i * 0.01) window.target_hist.append((t0, 30.0)) window.target_hist.append((t0 + 60, 35.0)) window.faults.append((t0 + 30, ff.SENSOR_OPEN)) window._set_theme('Dark') # 像素比对按 Dark 配色 window.chart.update() img = QImage(window.chart.size(), QImage.Format_ARGB32) window.chart.render(img) def count_pixels(img, rgb, tol=30): n = 0 for y in range(0, img.height(), 2): for x in range(0, img.width(), 2): c = img.pixelColor(x, y) if (abs(c.red() - rgb[0]) < tol and abs(c.green() - rgb[1]) < tol and abs(c.blue() - rgb[2]) < tol): n += 1 return n measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7 target = (232, 131, 58) # 目标线 #E8833A fault = (217, 83, 79) # 故障 #D9534F n_meas, n_tgt, n_flt = count_pixels(img, measured), count_pixels(img, target), count_pixels(img, fault) print(f'曲线像素={n_meas} 目标线像素={n_tgt} 故障像素={n_flt}') check('实测曲线已绘制', n_meas > 50, True) check('目标阶梯线已绘制', n_tgt > 5, True) check('NTC 故障标记已绘制', n_flt > 0, True) # 3) 帧派发状态机(经 _handle_frame, 等价真实串口路径) n0 = len(window.samples_t) window._handle_frame(mk_frame('24.5'), time.time()) check('上报帧入曲线', len(window.samples_t), n0 + 1) check('大字读数更新', window.lbl_temp.text(), '24.5 ℃') window._pending = deque([{'cmd': 'M4', 'kind': 'target', 't': time.time(), 'apply': None}]) window._handle_frame(mk_frame('120.0'), time.time()) check('M4 应答拆到目标', window.target_hist[-1][1], 120.0) check('目标 chip 更新', window.chip_target.text(), '目标: 120 ℃') check('pending 已消费', len(window._pending), 0) window._pending = deque([{'cmd': 'M4', 'kind': 'target', 't': time.time() - 2, 'apply': None}]) n1 = len(window.samples_t) window._handle_frame(mk_frame('33.3'), time.time()) check('过期 pending 不拦截温度帧', len(window.samples_t), n1 + 1) check('目标不被污染', window.target_hist[-1][1], 120.0) window._pending.clear() window._pending = deque([{'cmd': 'M2', 'kind': 'report', 't': time.time(), 'apply': None}]) window._handle_frame(mk_frame('ON 500'), time.time()) check('M2 状态解析', window.report_state, ('ON', 500)) check('上报 chip 更新', window.chip_report.text(), '上报: ON 500ms') window._pending = deque([{'cmd': 'M3', 'kind': 'heater', 't': time.time(), 'apply': None}]) window._handle_frame(mk_frame('OFF'), time.time()) check('M3 状态解析', window.heater_state, 'OFF') window._pending = deque([{'cmd': 'M4S77', 'kind': 'ack', 't': time.time(), 'apply': ('target', 77)}]) window._handle_frame(mk_frame('ok'), time.time()) check('M4S ack 落目标', window.target_hist[-1][1], 77.0) nf = len(window.faults) window._handle_frame(mk_frame('OPEN'), time.time()) check('OPEN 记故障', len(window.faults), nf + 1) check('故障断口 NaN', math.isnan(window.samples_v[-1]), True) check('传感器 chip 故障态', window.chip_sensor.text(), 'NTC: 开路(OPEN)') window._pending = deque([{'cmd': 'M999', 'kind': 'version', 't': time.time(), 'apply': None}]) window._handle_frame(mk_frame('v41.0.0.1'), time.time()) check('版本解析', window.firmware, 'v41.0.0.1') window._pending = deque([{'cmd': 'M4S300', 'kind': 'ack', 't': time.time(), 'apply': ('target', 300)}]) window._handle_frame(mk_frame('err', 0xFD), time.time()) check('FD 错误消费 pending', len(window._pending), 0) check('目标不被错误帧污染', window.target_hist[-1][1], 77.0) # 3b) 连接序列 FIFO 回归: M999/M2/M3/M4 同时在途, 应答按序到达 window._pending.clear() for cmd, kind in (('M999', 'version'), ('M2', 'report'), ('M3', 'heater'), ('M4', 'target')): window._pending.append({'cmd': cmd, 'kind': kind, 't': time.time(), 'apply': None}) window._handle_frame(mk_frame('v41.0.0.1'), time.time()) window._handle_frame(mk_frame('OFF'), time.time()) # M2 应答, 不得被 heater 吃掉 window._handle_frame(mk_frame('OFF'), time.time()) # M3 应答 window._handle_frame(mk_frame('0.0'), time.time()) # M4 应答, 不得混入曲线 check('FIFO 版本', window.firmware, 'v41.0.0.1') check('FIFO 上报', window.report_state, ('OFF', None)) check('FIFO 加热', window.heater_state, 'OFF') check('FIFO 目标', window.target_hist[-1][1], 0.0) check('FIFO 队列清空', len(window._pending), 0) # 3c) 杂散应答不消费队首: 队首等版本, 先来 OFF(杂散)再来 v41 window._pending.clear() window._pending.append({'cmd': 'M999', 'kind': 'version', 't': time.time(), 'apply': None}) window._handle_frame(mk_frame('OFF'), time.time()) check('杂散不消费队首', len(window._pending), 1) window._handle_frame(mk_frame('v41.0.0.1'), time.time()) check('杂散后正常消费', len(window._pending), 0) # 4) 主题往返后曲线配色随动 + 渲染不崩 window._set_theme('Light') check('Light 曲线配色', window.chart._colors['measured'].lower(), app_fluent.CHART_COLORS_LIGHT['measured']) window._set_theme('Dark') check('Dark 曲线配色', window.chart._colors['measured'].lower(), app_fluent.CHART_COLORS_DARK['measured']) window._set_theme('Light') window._set_theme('System') # 5) CSV 记录往返 window._toggle_csv() window._add_sample(time.time(), 25.1) window._record_fault(time.time(), ff.SENSOR_SHORT) window._toggle_csv() with open(window._csv_path, encoding='utf-8-sig') as f: lines = f.read().strip().splitlines() check('CSV 表头+2 行', len(lines), 3) check('CSV 故障行', lines[2].split(',')[2], 'SHRT') print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') sys.exit(1 if fails else 0)