#!/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 tempfile 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 import config from PySide6.QtCore import QRectF from PySide6.QtGui import QImage, QPainter from PySide6.QtWidgets import QApplication # 设置文件指向临时目录: 隔离用户真实 filmforge_gui.json(用户可能展存着手动 # Y 轴等状态), 测试一律从"首次启动默认"出发, 残留写入不留真实配置盘 _tmpdir = tempfile.mkdtemp(prefix='filmforge_selftest_') config.SETTINGS_FILE = os.path.join(_tmpdir, 'settings.json') 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 def count_in(img, x0, x1, y0, y1, rgb, tol=30): """区域内逐像素计数(stride 1): 用于条带级断言(如越界裁剪)与空态提示""" n = 0 for y in range(y0, y1): for x in range(x0, x1): 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 # 8) v0.2.11 回归: 右缘双把手滑条拖拽量程 + 空态提示实时消失。 # (提示不消失根因: 旧版 set_source 构建期缓存 _has_data 布尔, 数据经引用追加 # 后永不刷新 → 改绘制时实时判定; 本段同时覆盖滑条命中/拖拽/钳位/平移/裁剪) c = window.chart check('Y 轴初始自动', c._y_mode, 'auto') check('Y 轴按钮默认自动', window.btn_ymode.isChecked() and window.btn_ymode.text() == 'Y轴自动', True) engaged, ranges = [], [] c.yManualEngaged.connect(lambda: engaged.append(1)) c.yRangeChanged.connect(lambda lo, hi: ranges.append((lo, hi))) c.update() app.processEvents() # 同步一次绘制: 命中缓存与当前数据一致 area, _t0, _t1, _vt, _vv, _tgt, ymin0, ymax0 = c._layout() gx0, gx1 = c._gutter() ref0 = c._y_ref() y_hi_px = c._rail_px(area, ref0, ymax0) y_lo_px = c._rail_px(area, ref0, ymin0) check('把手命中(上把手)', c._gutter_hit(gx0 + 6, y_hi_px), 'max') check('把手命中(下把手)', c._gutter_hit(gx0 + 6, y_lo_px), 'min') check('轨道空白亦视为边界', c._gutter_hit(gx0 + 6, area.bottom() - 3), 'min') check('栏外不命中', c._gutter_hit(10, 100) is None, True) c.start_y_drag('min', y_lo_px) check('拖拽即切手动', c._y_mode, 'manual') check('手动信号已发', engaged, [1]) # 滑条域固定 -20~120 后拖拽映射恒定(0.53C/px 量级), 断言用目标值反算像素, # 不再依赖域宽: 拖到 ymin0+30 对应位置 → 下限抬升 30℃ c.update_y_drag(c._rail_px(area, ref0, ymin0 + 30.0)) lo1, hi1 = c._y_manual check('下限随拖拽抬升', lo1 > ymin0 + 1e-9, True) check('上限不受牵动', abs(hi1 - ymax0) < 1e-9, True) check('松手生效', c.end_y_drag(), True) lo_e, hi_e = ranges[-1] check('范围量化 0.1', abs(lo_e * 10 - round(lo_e * 10)) < 1e-6 and abs(hi_e * 10 - round(hi_e * 10)) < 1e-6, True) c.start_y_drag('min', y_lo_px) # 极端上拉 → 钳到 上限-最小间距 c.update_y_drag(area.top() - 500) check('最小间距约束', c._y_manual[1] - c._y_manual[0] >= c.MIN_Y_SPAN - 1e-9, True) c.end_y_drag() lo_prev, hi_prev = c._y_manual[0], c._y_manual[1] # 中带平移: 整段同移 c.start_y_drag('win', (y_hi_px + y_lo_px) / 2) c.update_y_drag(area.bottom() + 30) c.end_y_drag() lo_pan, hi_pan = c._y_manual # 松手量化 0.1℃ 各自取整, 跨度允许 ±0.05 偏差(两段半界) check('平移不改跨度(量化容差)', abs((hi_pan - lo_pan) - (hi_prev - lo_prev)) < 0.12, True) check('平移已生效', lo_pan < lo_prev - 1e-9, True) check('上下限同步移动', abs((lo_prev - lo_pan) - (hi_prev - hi_pan)) < 0.12, True) check('范围通知已发 3 次', len(ranges), 3) window.btn_ymode.setChecked(True) # 按钮勾回自动 check('切回自动', c._y_mode, 'auto') check('按钮文本回自动', window.btn_ymode.text(), 'Y轴自动') # 确定形状的像素断言: 手动 [28,80], 数据(~19~60℃)出界部分必被裁剪回绘图区 c.set_y_manual(28.0, 80.0) _hx = c._colors['measured'].lstrip('#') measured_now = (int(_hx[0:2], 16), int(_hx[2:4], 16), int(_hx[4:6], 16)) window.chart.update() img = QImage(window.chart.size(), QImage.Format_ARGB32) window.chart.render(img) area = QRectF(window.chart.rect()).adjusted( 52, 10, -(app_fluent.RIGHT_MARGIN + app_fluent.GUTTER_W), -26) x0, x1 = int(area.left()) + 2, int(area.right()) - 2 check('手动范围下曲线仍绘制', count_pixels(img, measured_now) > 0, True) check('裁剪: 上条带无曲线像素', count_in(img, x0, x1, 0, int(area.top()) - 1, measured_now), 0) check('裁剪: 下条带无曲线像素', count_in(img, x0, x1, int(area.bottom()), window.chart.height(), measured_now), 0) c.set_y_mode('auto') hint = (201, 201, 201) # 提示文字色 #c9c9c9 c2 = app_fluent.TemperatureChart() c2.resize(640, 320) lst_t, lst_v = [], [] c2.set_source(lst_t, lst_v, [], []) # 模拟构建期一次性注入(空) img2 = QImage(c2.size(), QImage.Format_ARGB32) c2.render(img2) ax0, ax1 = 60, c2.width() - 20 ax_top, ax_bot = 30, c2.height() - 30 n_hint0 = count_in(img2, ax0, ax1, ax_top, ax_bot, hint) lst_t.append(time.time()) # 经同一引用追加数据(复现旧 bug 路径) lst_v.append(50.0) c2.update() img3 = QImage(c2.size(), QImage.Format_ARGB32) c2.render(img3) n_hint1 = count_in(img3, ax0, ax1, ax_top, ax_bot, hint) print(f'提示像素: 空={n_hint0} 有数据={n_hint1}') check('空态提示已绘出', n_hint0 > 50, True) check('数据到位后提示消失(实时判定)', n_hint1, 0) # 8b) 值标签悬停门控: 非悬停不画(不常驻遮挡曲线, 用户标注), 悬停/拖拽才显示 lt2, lv2 = [time.time() - 100], [35.0] c2.set_source(lt2, lv2, [], []) c2.set_y_manual(20.0, 60.0) c2.set_y_mode('manual') c2.update() img4 = QImage(c2.size(), QImage.Format_ARGB32) c2.render(img4) zx0, zx1 = c2.width() - 71, c2.width() - 31 # 把手值标签区(绘图区右缘) zy0, zy1 = 110, 230 # 覆盖上下两个把手 y 位 n_pill0 = count_in(img4, zx0, zx1, zy0, zy1, (0, 200, 215)) c2._set_y_hover(True) c2.update() img5 = QImage(c2.size(), QImage.Format_ARGB32) c2.render(img5) n_pill1 = count_in(img5, zx0, zx1, zy0, zy1, (0, 200, 215)) print(f'值标签像素: 未悬停={n_pill0} 悬停={n_pill1}') check('未悬停时值标签隐藏(不遮曲线)', n_pill0, 0) check('悬停/拖拽时值标签显示', n_pill1 > 20, True) c2._set_y_hover(False) # 还原模块配置, 避免测试残留写入用户 filmforge_gui.json config.Y_MODE, config.Y_MIN, config.Y_MAX = 'auto', None, None config.save_settings() print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') sys.exit(1 if fails else 0)