Files
FilmForge/GUI/selftest_gui.py
hulk 000781e2f5 GUI v0.2.13: chart Y-axis range control (right-edge dual-handle slider)
- TemperatureChart gains a right-edge slider on a FIXED -20..120C rail
  (user-specified): top handle = upper bound, bottom handle = lower bound,
  drag middle band = pan keeping span, click free rail = snap that bound.
  First drag engages manual mode (yManualEngaged -> sync button state +
  config); release quantizes to 0.1C via yRangeChanged, min span 0.5C.
  Bound values during drag must come from live display range, not the
  stale paint cache (two drags without repaint made pan jump the range).
- Auto/manual toggle moved into the card header (was a wrapped second
  row); pause/clear merged into header too; legend moved to top-left.
- Value labels next to handles only visible on hover/drag (constant
  labels covered the curve per user feedback).
- Out-of-range curves clipped back to the plot area (setClipRect);
  current-value marker suppressed outside the manual range.
- Draft textbox rows from v0.2.10 superseded; versions v0.2.10..v0.2.13.
- Fixed "no data yet" hint never disappearing: set_source cached a
  stale _has_data bool once at build time while data flows through the
  same list references; now evaluated live at paint time.
- config: y_axis key persists mode/bounds; invalid stored values fall
  back to auto. selftest settings isolated to a temp dir (user json with
  stored manual state broke the fresh-default assertions).
- selftest_gui: slider hit/drag/clamp/pan/clipping regression + hint
  gating; 83 checks all pass.
2026-10-15 21:30:21 +08:00

425 lines
18 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 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)