Files
FilmForge/GUI/selftest_gui.py
T

232 lines
10 KiB
Python

#!/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()
# 0) 图标链路: logos/filmforge.ico 存在时窗口图标必须已加载
check('窗口图标已设置', not window.windowIcon().isNull(), True)
# 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)
# 3d) 晚到 ok 补偿: 设定命令超时清队后, 迟到的 ok 仍按发送时计划应用
window._pending.clear()
window._last_set = {'cmd': 'M2S500', 't': time.time(), 'apply': ('report', ('ON', 500))}
window._handle_frame(mk_frame('ok'), time.time())
check('晚到 ok 补偿上报', window.report_state, ('ON', 500))
check('晚到 ok 已清记录', window._last_set, None)
# 3e) M4 暂停-恢复链: 上报开启时查目标 → M2S0(ok)→M4→应答落地→自动恢复上报
window._pending.clear()
window.link.is_connected = lambda: True # 仅测试: 跳过串口在线检查
window._set_report_state('ON', 500)
window._query_target()
check('查目标先停上报', window._pending[0]['cmd'], 'M2S0')
check('挂起恢复标记', window._resume_report_ms, 500)
window._handle_frame(mk_frame('ok'), time.time()) # M2S0 ok → then 定时发 M4
window._pending.clear()
window._pending.append({'cmd': 'M4', 'kind': 'target', 't': time.time(), 'apply': None})
window._handle_frame(mk_frame('57.0'), time.time()) # M4 应答(替换定时器路径)
check('M4 应答落目标', window.target_hist[-1][1], 57.0)
check('恢复标记已清', window._resume_report_ms, None)
check('恢复上报已入队', window._pending[0]['cmd'], 'M2S500')
window.link.is_connected = lambda: False # 还原
window._pending.clear()
# 3f) 上报开启时, 上报帧不得被当成 M4 目标应答误占(队首非 target 时入曲线)
window._set_report_state('ON', 100)
n2 = len(window.samples_t)
window._handle_frame(mk_frame('60.0'), time.time())
check('无 pending 上报帧入曲线', len(window.samples_t), n2 + 1)
window._set_report_state('OFF', None)
# 3g) 幂等去重: 同命令未决时重复发送被忽略, 队列不再堆积(真机连点 M2S0 回归)
window._pending.clear()
window.link.is_connected = lambda: True
window._send_set('M2S0', 'report', ('OFF', None))
window._send_set('M2S0', 'report', ('OFF', None))
window._send_set('M2S0', 'report', ('OFF', None))
check('重复命令去重', len(window._pending), 1)
check('不同命令不去重', 'M4' if window._send_cmd('M4', 'target') else '', 'M4')
check('去重后队列 2 条', len(window._pending), 2)
window.link.is_connected = lambda: False
window._pending.clear()
# 3h) 目标阶梯线: 目标变更须在变更时刻垂直落变, 不得从旧值斜拉到右缘
t_a, t_b, t_now = time.time() - 120, time.time() - 60, time.time()
window.chart._target_hist = [(t_a, 57.0), (t_b, 41.0)]
pts = window.chart._target_points(t_a, t_now)
check('目标线终点延到右缘', abs(pts[-1][0] - t_now) < 1e-6 and pts[-1][1], 41.0)
diag = [1 for (xa, ya), (xb, yb) in zip(pts, pts[1:])
if abs(xb - xa) > 1e-9 and abs(yb - ya) > 1e-9]
check('目标线无斜段(纯阶梯)', diag, [])
check('变更时刻有垂直落变', (t_b, 57.0) in pts and (t_b, 41.0) in pts, True)
window.chart._target_hist = [] # 还原
# 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)