#!/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') # 6) 日志环形裁剪回归: 写满 500 行后封顶, 继续追加不抛异常、不无限增长。 # (手工 cursor 裁剪用 cursor.Start/BlockUnderCursor 实例级枚举访问, # PySide6 6.11 上必然抛 AttributeError → 每 tick 异常中止, v0.2.8 改原生裁剪) for i in range(600): window._log(f'日志压力行 {i}') check('日志 500 行封顶', window.txt_log.blockCount() <= 500, True) window._log('封顶后追加行') check('封顶后可继续追加', window.txt_log.blockCount() <= 500, True) check('封顶后最新行在末尾', window.txt_log.toPlainText().splitlines()[-1].endswith('封顶后追加行'), True) # 7) v0.2.9 回归: 故障标记封顶 / 滚动条跟随 / CSV 写失败自停 / 冻结切片右边界 t_end = time.time() for i in range(app_fluent.MAX_FAULTS + 50): window._record_fault(t_end + i * 0.01, ff.SENSOR_OPEN) check('故障标记封顶', len(window.faults), app_fluent.MAX_FAULTS) sb = window.txt_log.verticalScrollBar() sb.setValue(0) window._log('上翻后新行') check('上翻时不抢滚动条', sb.value() <= 1, True) sb.setValue(sb.maximum()) window._log('贴底跟随行') check('贴底时跟随滚动', abs(sb.value() - sb.maximum()) <= 4, True) class _BoomFile: """模拟写坏盘: write/flush 必抛 OSError(磁盘满/记录盘被拔)""" def write(self, *_a): raise OSError(28, '模拟磁盘满') def flush(self): raise OSError(28, '模拟磁盘满') def close(self): pass window._csv = _BoomFile() window._csv_path = 'dummy.csv' window._add_sample(time.time(), 25.0) check('CSV 写失败自动停止记录', window._csv is None, True) check('CSV 失败后按钮复位', window.btn_csv.text(), '开始记录') c = window.chart c._samples_t, c._samples_v = [1.0, 2.0, 3.0, 4.0], [1, 2, 3, 4] c._frozen_at = 2.5 ts, _vs = c._visible(0, 2.5) check('冻结切片不含冻结尾后样本', ts, [1.0, 2.0]) c._frozen_at = None ts, _vs = c._visible(0, 99) check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0]) c._samples_t, c._samples_v = window.samples_t, window.samples_v print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') sys.exit(1 if fails else 0)