Files

364 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""界面回归检查(开发/验收用):布局几何 + 像素颜色区域校验 + 截图。
AI 无法直接查看截图,所以这里用两种可判定的方式验证界面确实渲染正确:
1. 遍历控件树,检查是否有零尺寸 / 越界 / 被横向裁掉的控件,
并用字体度量核对关键文本(大号计时器、状态栏)不会被截断;
2. 读取渲染结果的像素,按区域统计颜色,确认电平条、录音按钮、
示波器、整段总览等自绘控件确实画出了预期颜色。
还会真实录音 2.6 秒,验证「开始录音 → 界面刷新 → 停止 → 仪表冻结」链路。
用法::
python dev/gui_check.py # 离屏运行,不需要显示器
python dev/gui_check.py --shots # 额外把截图存到 dev/shots/
"""
import os, sys, time, collections
SHOT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
SAVE_SHOTS = "--shots" in sys.argv
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtWidgets import (QApplication, QWidget, QComboBox, QLabel, QPushButton,
QGroupBox, QScrollArea, QSpinBox, QDoubleSpinBox,
QLineEdit, QCheckBox, QSlider)
from PyQt5.QtGui import QFont, QImage
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
app = QApplication([])
from recorder.gui import QSS
app.setStyleSheet(QSS)
app.setFont(QFont("Microsoft YaHei UI", 9))
from recorder.window import MainWindow
from recorder import engine
from recorder.widgets import ACCENT, REC, OK, WARN
fails = []
def check(name, ok, detail=""):
print((" ✓ " if ok else " ✗ ") + name + ((" " + detail) if detail else ""))
if not ok:
fails.append(name)
def walk(w, depth=0, out=None):
out = [] if out is None else out
out.append((depth, w))
for c in w.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
walk(c, depth + 1, out)
return out
print("── 布局几何检查 " + "─" * 50)
win = MainWindow()
win.resize(1280, 900)
out_dir = SHOT_DIR
os.makedirs(out_dir, exist_ok=True)
win.dir_edit.setText(os.path.join(out_dir, "rec"))
win.auto_report_check.setChecked(False)
win.show()
for _ in range(40):
app.processEvents()
time.sleep(0.01)
if SAVE_SHOTS:
win.grab().save(os.path.join(SHOT_DIR, "01_idle.png"))
nodes = walk(win)
print(f"控件总数:{len(nodes)}")
zero = [w for _d, w in nodes
if w.isVisible() and (w.width() <= 1 or w.height() <= 1)
and not isinstance(w, (QScrollArea,))]
check("没有零尺寸的可见控件", not zero,
"、".join(f"{type(w).__name__}" for w in zero[:6]))
# 关键控件必须可见且有合理尺寸
for name, w, min_w, min_h in (
("录音按钮", win.rec_button, 60, 60),
("电平表", win.meter, 300, 60),
("示波器", win.scope, 300, 120),
("响度条", win.loudness, 300, 40),
("总览图", win.history, 300, 60),
("定时显示", win.time_label, 120, 30),
):
check(f"{name}尺寸合理", w.isVisible() and w.width() >= min_w and w.height() >= min_h,
f"{w.width()}×{w.height()}")
# 控件不得超出窗口(滚动区内的控件按内容坐标滚动,单独检查其水平适配)
over = []
for _d, w in nodes:
if not w.isVisible() or w is win:
continue
anc, in_scroll = w.parent(), False
while anc is not None:
if isinstance(anc, QScrollArea):
in_scroll = True
break
anc = anc.parent()
if in_scroll:
continue
tl = w.mapTo(win, w.rect().topLeft() - w.rect().topLeft())
if tl.x() + w.width() > win.width() + 2 or tl.y() + w.height() > win.height() + 2 \
or tl.x() < -2 or tl.y() < -2:
over.append(f"{type(w).__name__}@{tl.x()},{tl.y()} {w.width()}×{w.height()}")
check("没有控件越出窗口边界", not over, ";".join(over[:5]))
# 关键文本不得被截断(用字体度量判断)
for name, w, sample in (("定时显示", win.time_label, "10:59:59"),
("状态栏左侧", win.status_left, "正在录音:D:\\Recordings")):
need = w.fontMetrics().horizontalAdvance(sample)
check(f"{name}能完整显示最长文本", w.width() >= need,
f"需要 {need}px,实得 {w.width()}px")
# 左面板不该被横向裁掉(滚动区内容宽度 <= 视口宽度 + 容差)
scroll = win.findChild(QScrollArea)
if scroll is not None:
inner = scroll.widget()
check("设置面板内容宽度适配视口(无横向裁切)",
inner.minimumSizeHint().width() <= scroll.viewport().width() + 2,
f"内容最小 {inner.minimumSizeHint().width()} / 视口 {scroll.viewport().width()}")
def region_colors(pixmap, rect, targets, tol=40):
"""统计 rect 区域内与各目标颜色接近的像素比例。"""
img = pixmap.toImage().convertToFormat(QImage.Format_RGB32)
counts = collections.Counter()
total = 0
for y in range(rect.top(), min(rect.bottom(), img.height()), 2):
for x in range(rect.left(), min(rect.right(), img.width()), 2):
c = img.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
total += 1
for name, (tr, tg, tb) in targets.items():
if abs(r - tr) < tol and abs(g - tg) < tol and abs(b - tb) < tol:
counts[name] += 1
break
return {k: v / max(1, total) for k, v in counts.items()}, total
def to_rgb(hexstr):
hexstr = hexstr.lstrip("#")
return tuple(int(hexstr[i:i + 2], 16) for i in (0, 2, 4))
print("\n── 空闲状态像素校验 " + "─" * 46)
pm = win.grab()
targets = {"面板": to_rgb("#161A22"), "背景": to_rgb("#0F1218"),
"强调蓝": to_rgb(ACCENT), "录音红": to_rgb(REC)}
win_rect = QRect(0, 0, win.width(), win.height())
ratios, total = region_colors(pm, win_rect, targets)
print(f" 采样 {total} 点:面板 {ratios.get('面板', 0):.1%},"
f"背景 {ratios.get('背景', 0):.1%},录音红 {ratios.get('录音红', 0):.1%}")
check("界面不是一片黑(面板/背景色占比正常)",
ratios.get("面板", 0) + ratios.get("背景", 0) > 0.4)
br, br_total = region_colors(
pm, QRect(win.rec_button.mapTo(win, win.rec_button.rect().topLeft()),
win.rec_button.size()), {"红": to_rgb(REC)})
check("空闲时录音按钮显示红色圆点", br.get("红", 0) > 0.05,
f"按钮区域内红色占比 {br.get('红', 0):.1%}")
print("\n── 录音状态像素校验 " + "─" * 46)
for i in range(win.device_combo.count()):
d = win.device_combo.itemData(i)
if d is not None:
dev = [x for x in win._devices if x.index == d]
if dev and dev[0].quality_rank == 0:
win.device_combo.setCurrentIndex(i)
break
win.gain_slider.setValue(150)
win.start_record()
t0 = time.time()
while time.time() - t0 < 2.2:
app.processEvents()
time.sleep(0.02)
win.add_marker()
while time.time() - t0 < 2.6:
app.processEvents()
time.sleep(0.02)
pm2 = win.grab()
if SAVE_SHOTS:
pm2.save(os.path.join(SHOT_DIR, "02_recording.png"))
def _is_meter_fill(r, g, b) -> bool:
"""电平条填充色:饱和度高的绿/黄/红/深蓝;排除灰白文字与白色峰值线。"""
if max(r, g, b) < 110 or min(r, g, b) > 150:
return False
return (max(r, g, b) - min(r, g, b)) > 45
def meter_fill_extent(lane: int = 0) -> float:
"""返回某个通道电平条"填充到最右边的位置"占表宽的百分比。"""
img = win.meter.grab().toImage().convertToFormat(QImage.Format_RGB32)
w, h = img.width(), img.height()
channels = win.meter.channels
top, footer = 4, 20
per = (h - top - footer) / channels
y0 = max(0, int(top + lane * per))
y1 = min(h - 1, int(top + (lane + 1) * per) - 1)
best = 0
for y in range(y0, y1):
for x in range(w - 1, -1, -1):
c = img.pixel(x, y)
if _is_meter_fill((c >> 16) & 255, (c >> 8) & 255, c & 255):
best = max(best, x)
break
return best / w
# 确定性校验 dB -> 像素映射:注入已知电平,比较填充长度
# (现场麦克风可能接近静音,直接看真实输入会让这个检查随环境漂移。
# 同时必须暂停界面刷新定时器,否则注入的值会被真实电平覆盖。)
from recorder import dsp as _dsp # noqa: E402
win.timer.stop()
def inject(peak_db, rms_db=None, clipped=False):
rms_db = peak_db if rms_db is None else rms_db
win.meter.update_levels(_dsp.MeterSnapshot(
rms_db=list(rms_db), peak_db=list(peak_db), hold_db=list(peak_db),
true_peak_db=list(peak_db), clipped=[clipped] * len(peak_db)))
app.processEvents()
inject([-3.0] * 2)
loud = meter_fill_extent(0)
inject([-30.0] * 2)
quiet = meter_fill_extent(0)
inject([-60.0] * 2)
floor = meter_fill_extent(0)
check("电平表按 dB 线性映射为像素长度",
loud > quiet + 0.15 and quiet > floor + 0.15 and loud > 0.85,
f"-3 dB → {loud:.0%},-30 dB → {quiet:.0%},-60 dB → {floor:.0%} 表宽")
inject([0.0, 0.0], clipped=True)
img = win.meter.grab().toImage().convertToFormat(QImage.Format_RGB32)
red = 0
tot = 0
for y in range(0, img.height(), 2):
for x in range(0, img.width(), 2):
c = img.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
tot += 1
if r > 200 and g < 130 and b < 140:
red += 1
check("削波锁存时电平表显示红色警示", red / max(1, tot) > 0.002,
f"红色占比 {red / max(1, tot):.2%}")
inject([-14.0, -14.0], rms_db=[-20.0, -20.0]) # 恢复成接近真实输入的样子
win.timer.start()
app.processEvents()
pix = pm2.toImage().convertToFormat(QImage.Format_RGB32)
scope_rect = QRect(win.scope.mapTo(win, win.scope.rect().topLeft()), win.scope.size())
sc_lit = 0
sc_total = 0
for y in range(scope_rect.top(), scope_rect.bottom(), 2):
for x in range(scope_rect.left(), scope_rect.right(), 2):
c = pix.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
sc_total += 1
if b > 150 and b > r + 40:
sc_lit += 1
check("示波器画出了波形(蓝色像素)", sc_lit / max(1, sc_total) > 0.001,
f"{sc_lit}/{sc_total} = {sc_lit/max(1,sc_total):.2%}")
hist_rect = QRect(win.history.mapTo(win, win.history.rect().topLeft()),
win.history.size())
h_lit = 0
h_total = 0
for y in range(hist_rect.top(), hist_rect.bottom(), 2):
for x in range(hist_rect.left(), hist_rect.right(), 2):
c = pix.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
h_total += 1
if b > 150:
h_lit += 1
check("整段总览图有波形", h_lit > 0, f"{h_lit} 个采样点")
snap = win.recorder.live()
check("录音统计在更新", snap.elapsed > 1.5 and snap.bytes_written > 0,
f"{snap.elapsed:.2f}s,{snap.bytes_written} 字节,"
f"峰值 {snap.peak_dbfs:+.1f} dBFS")
check("定时器显示已刷新", win.time_label.text() != "00:00.0", win.time_label.text())
check("状态标签显示文件名", "wav" in win.pill_file.text, win.pill_file.text)
check("录音按钮切换为录音态", win.rec_button.state == "recording", win.rec_button.state)
check("参数控件在录音中被禁用", not win.device_combo.isEnabled()
and not win.rate_combo.isEnabled())
check("传输按钮在录音中可用", win.stop_btn.isEnabled() and win.marker_btn.isEnabled()
and win.pause_btn.isEnabled())
win.stop_record()
for _ in range(20):
app.processEvents()
time.sleep(0.01)
check("停止后回到空闲态", win.recorder is None and win.rec_button.state == "idle")
check("停止后有录音结果", win.result is not None and bool(win.result.files),
f"{len(win.result.files) if win.result else 0} 个文件")
check("停止后电平表冻结显示最后一次读数(便于回看峰值)",
win.meter._peak[0] > -60, f"{win.meter._peak[0]:.1f} dBFS")
check("停止后定时显示保留最终时长", win.time_label.text() != "00:00.0",
win.time_label.text())
check("暂停/标记/分段按钮停止后禁用", not win.stop_btn.isEnabled())
# 重新开始录音时仪表盘必须清零
win.start_record()
for _ in range(10):
app.processEvents()
time.sleep(0.01)
check("重新开始时电平表已复位", win.meter._clip == [False] * win.meter.channels,
f"削波锁存 {win.meter._clip}")
win.stop_record()
for _ in range(10):
app.processEvents()
time.sleep(0.01)
if SAVE_SHOTS:
win.grab().save(os.path.join(SHOT_DIR, "03_after.png"))
from recorder.window import ReportDialog
if win.result:
dlg = ReportDialog(win.result, win)
dlg.resize(760, 620)
dlg.show()
for _ in range(10):
app.processEvents()
dlg.grab().save(os.path.join(SHOT_DIR, "04_report.png"))
dlg.close()
print(f"截图已保存到 {SHOT_DIR}")
print("\n── 静音自动停止 → 界面自动收尾 " + "─" * 33)
win.auto_stop.setValue(0.6)
win.silence_thr.setValue(-20.0) # 阈值故意设高,保证当前环境一定触发
win.start_record()
check("自动停止测试已开始录音", win.recorder is not None)
t0 = time.time()
while time.time() - t0 < 8:
app.processEvents()
time.sleep(0.02)
if win.recorder is None:
break
check("引擎自动停止后界面自动收尾(不再停在「录音中」)",
win.recorder is None and win.rec_button.state == "idle",
f"用时 {time.time() - t0:.2f} 秒")
check("自动停止也产生了可用的录音结果",
win.result is not None and bool(win.result.files),
f"{win.result.duration:.2f}s,{len(win.result.files)} 个文件"
if win.result else "无结果")
check("自动停止后传输按钮已禁用", not win.stop_btn.isEnabled())
win.auto_stop.setValue(0.0)
win.silence_thr.setValue(-50.0)
print("\n" + "=" * 62)
if fails:
print(f"失败 {len(fails)} 项:" + "、".join(fails))
sys.exit(1)
print("界面程序化校验全部通过")