test: 增加测试套件

This commit is contained in:
mofeng-git
2026-07-05 21:30:11 +08:00
parent b346af35d3
commit 696c1b4bb2
10 changed files with 4486 additions and 0 deletions

4
test/okvm-test/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
reports/
bin/*.exe

187
test/okvm-test/README.md Normal file
View File

@@ -0,0 +1,187 @@
# One-KVM 自动化产测工具
工具由两部分组成:
- `okvm_testctl.py`Windows 本地控制端。负责 SSH 操作、One-KVM HTTP API 调用、MJPEG/WebRTC 客户端、HID 检测、MSD 检测、ATX API 检测和报告输出。`run` 子命令只支持原生 Windows不支持 WSL/Linux。
- `agent/`Windows 被控机配套程序。使用 Go 编写,编译为单文件 `.exe`
## 编译 Windows 配套程序
在 Windows PowerShell 安装 Go 后执行:
```powershell
cd test/okvm-test/agent
New-Item -ItemType Directory -Force ..\bin
go build -o ..\bin\okvm-win-agent-amd64.exe .
```
编译产物输出到:
```text
test/okvm-test/bin/okvm-win-agent-amd64.exe
```
将该 exe 放到被控 Windows 机器上,在开始产测前运行:
```powershell
.\okvm-win-agent-amd64.exe -listen 0.0.0.0:8765
```
Windows 配套程序会监听所有网卡地址,本地控制端通过 `--agent-host <Windows测试机IP>` 主动连接它。配套程序默认隐藏测试窗口只有视频延迟、HID 等需要采集画面/输入时才显示。
## 安装本地控制端依赖
Windows 控制端使用 PowerShell
```powershell
cd test/okvm-test
py -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m playwright install chrome
```
控制端固定使用 Google Chrome (`chrome`) 跑 WebRTC/H.265 测试,以便使用 Windows 核显/系统解码。所有 Chrome 启动都会加入 `--enable-features=WebRtcAllowH265Receive``--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled`
## 针对当前测试机运行
PowerShell
```powershell
cd test/okvm-test
$env:OKVM_SSH_PASSWORD="1234"
python okvm_testctl.py run `
--target 192.168.137.67 `
--ssh-user root `
--ssh-password-prompt `
--reset `
--agent-host <Windows测试机IP> `
--agent-port 8765
```
默认流程:
1. 通过 SSH 停止 `one-kvm`
2. 备份 `/etc/one-kvm/one-kvm.db`
3. 删除当前数据库,实现强制重置。
4. 启动 `one-kvm`
5. 采集控制端到目标机的 TCP connect 和 HTTP `/health` 往返延迟,调用初始化接口创建测试账号,登录后发现设备并应用测试配置。
6. 启用 OTG MSD 后,默认把本仓库的 Ventoy 资源同步到目标机 `/etc/one-kvm/ventoy`,并重启一次 `one-kvm` 让资源初始化生效。
7. 执行视频输入/输出、每个视频输入的 MJPEG 端到端画面延迟、HDMI 画面确认、颜色偏差、HID、MSD、ATX API 和性能检测。
8. 输出中文完整报告 `reports/<run-id>/report-YYYYMMDD-HHMMSS.md` 和面向用户的迷你摘要 `reports/<run-id>/user-report-YYYYMMDD-HHMMSS.md`,截图、日志和采集帧保存在 `reports/<run-id>/evidence/`
终端结果默认使用颜色区分:绿色为 `PASS`,黄色为 `WARN/SKIP`,红色为 `FAIL`。可通过 `--no-color``NO_COLOR=1` 关闭颜色。
## 视频测试规则
控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入:
- USB2.0 采集卡:测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV才退到不超过 1080p 的最高分辨率。
- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV才退到不超过 1080p 的最高分辨率。
- CSI/MIPI只测试一套 `1080p60 NV12`,不做输入格式切换。
每个输入配置都会跑三种输出:
- MJPEG/HTTP
- H.264 WebRTC
- H.265 WebRTC
默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。
MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps <fps>` 覆盖。
如果浏览器或编码器不支持 H.265,会记录为 `SKIP`
默认情况下,视频链路只要实际 fps 大于 0 就视为功能成功;报告保留实际 fps、RTT 和 jitter。需要把低帧率作为失败时`--strict-performance`
## HDMI 采集与视频延迟测试
连接 Windows agent 后,控制端会让 Windows 配套程序打开全屏纯色窗口,然后通过 One-KVM 的 MJPEG/HTTP 或浏览器 WebRTC 解码画面抓帧检测:
- `hdmi_identity`:确认 HDMI 采集画面确实跟随被控 Windows 画面变化,至少验证红、绿、蓝三种高饱和颜色。
- `hdmi_color_range`:采样画面中心区域,输出期望 RGB、实测 RGB 均值、标准差、平均误差和最大通道误差。
- `video_latency_<输入>_mjpeg` / `video_latency_<输入>_h264` / `video_latency_<输入>_h265`:每个被选中的视频输入 case 都会分别执行 MJPEG、H.264、H.265 延迟测试。Windows agent 延迟切换纯色Windows 控制端从 MJPEG 帧或 Chrome 解码后的 WebRTC video 帧中检测首次颜色变化,输出端到端视觉延迟 p50、p95、max。延迟测试不保存命中帧截图。
纯色采样不会再用第一帧直接判定;控制端会在超时时间内持续拉取 MJPEG等待目标颜色出现。若始终未匹配会保存最佳接近帧并在 Markdown 报告中写出实测 RGB、未命中数量和采样帧数。
默认颜色误差阈值:
- 平均 RGB 误差 `<=30``PASS`
- 平均 RGB 误差 `<=60``WARN`
- 平均 RGB 误差 `>60``FAIL`
默认视频视觉延迟阈值只用于基本功能判定:
- p95 `<=3000ms``PASS`
- p95 `>3000ms` 或未检测到目标颜色:`FAIL`
相关开关:
- `--no-hdmi-tests`:跳过 HDMI 画面、颜色和视频视觉延迟测试。
- `--hdmi-capture-timeout <秒>`:单个纯色采样等待目标颜色的最长时间,默认 8 秒。
- `--hdmi-match-frames <帧数>`:目标颜色需要连续匹配的帧数,默认 1静态 MJPEG 场景建议保持默认。
- `--hdmi-color-warn <level>` / `--hdmi-color-fail <level>`:调整颜色偏差阈值。
- `--video-latency-trials <次数>`:调整切色延迟测试次数,默认 5 次。旧名 `--hdmi-latency-trials` 仍兼容。
- `--video-latency-fail-ms <ms>`:调整基本功能阈值,默认 3000ms。旧名 `--hdmi-latency-fail-ms` 仍兼容。
WebRTC 性能表仍保留连接 RTT/jitter真实画面级延迟会额外按 MJPEG、H.264、H.265 逐输入输出。
## 网页截图证据
默认保留关键过程网页截图到 `reports/<run-id>/evidence/screenshots/`
- 登录后控制台首页。
- MJPEG 模式网页截图。
- H.264 WebRTC 模式网页截图。
- H.265 WebRTC 尝试网页截图。
- HID/MSD/ATX 测试后的控制台状态。
视频模式截图会等待页面脱离“等待首帧/连接中”等过渡状态后再保存H.265 不支持等真实失败页面会直接保留作为证据。等待时间可用 `--screenshot-wait-ms <ms>` 调整,默认 6000ms。
如需跳过网页截图,可加 `--no-screenshots`
## HID 测试
HID 测试依赖 Windows agent。流程如下
1. `/hid/status` 确认 HID 后端可用。
2. 键盘矩阵覆盖全部字母、数字、F1-F12以及不影响系统运行的 Ctrl/Shift + 功能键组合。
3. 鼠标矩阵覆盖绝对坐标移动和相对移动。
4. 键盘鼠标矩阵完成后,使用 F8 做 HID 延迟采样,输出 p50、p95、max。
HID 延迟默认 5 次采样,可通过 `--hid-latency-trials <次数>` 调整。默认 p95 `<=80ms``PASS``<=200ms``WARN`,更高为 `FAIL`;阈值可用 `--hid-latency-warn-ms <ms>``--hid-latency-fail-ms <ms>` 调整。
## MSD 测试
MSD 测试依赖 OTG。流程如下
1. 确认目标机 `/etc/one-kvm/ventoy` 存在 `boot.img``core.img``ventoy.disk.img`
2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。
3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。
4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。
5. 通过 `/api/msd/connect {"mode":"drive"}` 连接到 Windows。
6. Windows agent 等待新盘符出现。
7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。
8. 控制端断开 MSDWindows agent 确认盘符消失。
相关开关:
- `--ventoy-resources-dir <dir>`:指定本地 Ventoy 资源目录。
- `--no-ventoy-sync`:不向目标机同步 Ventoy 资源。
- `--no-msd-restart-after-enable`:启用 MSD 后不重启 `one-kvm`
## ATX 测试
默认不执行真实电源按键动作,因为当前测试环境不会连接真实 ATX 设备。
默认只验证:
- `/api/atx/status`
- `/api/config/atx`
- `/api/atx/wol`
## 注意事项
- `--reset` 会删除 One-KVM 当前 SQLite 数据库,虽然会先备份,但这是破坏性操作。
- 旧数据库不会自动恢复,备份保存在目标机 `/etc/one-kvm/test-backups/<run-id>/`
- 编译出的 `.exe` 和测试报告默认被 `.gitignore` 忽略。

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
arch="${GOARCH:-amd64}"
out="../bin/okvm-win-agent-${arch}.exe"
GOOS=windows GOARCH="$arch" CGO_ENABLED=0 \
go build -trimpath -ldflags "-s -w" -o "$out" .
echo "Built $out"

View File

@@ -0,0 +1,5 @@
module okvm-win-agent
go 1.22
require github.com/gorilla/websocket v1.5.3

View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

1202
test/okvm-test/agent/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import io
from typing import Any
HDMI_COLOR_SEQUENCE: tuple[tuple[str, str], ...] = (
("black", "#000000"),
("white", "#ffffff"),
("gray16", "#101010"),
("gray128", "#808080"),
("gray235", "#ebebeb"),
("red", "#ff0000"),
("green", "#00ff00"),
("blue", "#0000ff"),
)
def hex_to_rgb(value: str) -> tuple[int, int, int]:
s = value.strip().lstrip("#")
if len(s) != 6:
raise ValueError(f"invalid RGB color: {value}")
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
def rgb_error(expected: tuple[int, int, int], measured: tuple[float, float, float]) -> dict[str, float]:
errors = [abs(float(measured[i]) - float(expected[i])) for i in range(3)]
return {
"mean_abs_error": sum(errors) / 3,
"max_abs_error": max(errors),
}
def closest_hdmi_color(measured: tuple[float, float, float]) -> tuple[str, float]:
best_name = ""
best_error = 999.0
for name, color in HDMI_COLOR_SEQUENCE:
err = rgb_error(hex_to_rgb(color), measured)["mean_abs_error"]
if err < best_error:
best_name = name
best_error = err
return best_name, best_error
def jpeg_rgb_stats(frame: bytes) -> dict[str, Any]:
try:
from PIL import Image, ImageStat
except ImportError as exc:
raise RuntimeError("Pillow is required for HDMI color/latency tests; run pip install -r requirements.txt") from exc
with Image.open(io.BytesIO(frame)) as image:
rgb = image.convert("RGB")
width, height = rgb.size
left = max(0, int(width * 0.35))
top = max(0, int(height * 0.35))
right = min(width, int(width * 0.65))
bottom = min(height, int(height * 0.65))
crop = rgb.crop((left, top, right, bottom))
stat = ImageStat.Stat(crop)
return {
"width": width,
"height": height,
"sample_box": [left, top, right, bottom],
"mean_rgb": tuple(round(float(v), 2) for v in stat.mean),
"stddev_rgb": tuple(round(float(v), 2) for v in stat.stddev),
}
def percentile(values: list[float], pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * (pct / 100)
lower = int(rank)
upper = min(lower + 1, len(ordered) - 1)
weight = rank - lower
return ordered[lower] * (1 - weight) + ordered[upper] * weight

View File

@@ -0,0 +1,671 @@
from __future__ import annotations
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class CheckResult:
name: str
status: str
message: str = ""
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class Metric:
name: str
value: Any
unit: str = ""
labels: dict[str, Any] = field(default_factory=dict)
@dataclass
class Evidence:
category: str
title: str
path: str
STATUS_TEXT = {
"PASS": "通过",
"FAIL": "失败",
"WARN": "警告",
"SKIP": "跳过",
}
STATUS_COLOR = {
"PASS": "\033[32m",
"FAIL": "\033[31m",
"WARN": "\033[33m",
"SKIP": "\033[33m",
"INFO": "\033[36m",
}
CATEGORIES_WITH_DATA_TABLE = {
"初始化与环境",
"视频性能",
"HDMI 画面与颜色",
"HID / MSD / ATX",
}
CATEGORIES_WITHOUT_DATA_TABLE: set[str] = set()
REPORT_HIDDEN_RESULTS = {
"log_collection",
"screenshot",
}
CATEGORY_RULES = (
("初始化与环境", ("target_", "setup_", "login", "network_", "target_inventory", "stream_codecs", "windows_agent", "ventoy_resources", "msd_restart")),
("视频性能", ("video_", "config_video_")),
("HDMI 画面与颜色", ("hdmi_", "config_video_hdmi_probe")),
("HID / MSD / ATX", ("hid_", "msd", "atx_")),
)
DISPLAY_NAMES = {
"target_reset": "目标机重置",
"setup_init": "初始化账号",
"login": "登录",
"login_after_restart": "重启后登录",
"network_latency": "网络延迟",
"target_inventory": "目标机设备清单",
"hid_msd_config": "HID/MSD 配置",
"ventoy_resources": "Ventoy 资源",
"msd_restart": "MSD 启用后重启",
"video_input_select": "视频输入选择",
"windows_agent": "Windows 配套程序连接",
"config_video_hdmi_probe": "HDMI 采集配置",
"hdmi_identity": "HDMI 画面来源确认",
"hdmi_color_range": "HDMI 颜色偏差",
"hdmi_latency_mjpeg": "MJPEG 画面延迟",
"hid_status": "HID 状态",
"hid_input": "HID 输入",
"hid_latency": "HID 延迟",
"msd": "MSD 虚拟盘",
"atx_api": "ATX API",
"log_collection": "日志采集",
"screenshot": "网页截图",
}
class Reporter:
def __init__(self, report_dir: Path, color: bool | None = None):
self.report_dir = report_dir
self.results: list[CheckResult] = []
self.metrics: list[Metric] = []
self.evidence: list[Evidence] = []
self.report_dir.mkdir(parents=True, exist_ok=True)
(self.report_dir / "evidence").mkdir(exist_ok=True)
self.use_color = color if color is not None else (sys.stdout.isatty() and not os.environ.get("NO_COLOR"))
def add(self, check_name: str, outcome: str, detail: str = "", **data: Any) -> None:
self.results.append(CheckResult(name=check_name, status=outcome, message=detail, data=data))
prefix = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIP": "[SKIP]", "WARN": "[WARN]"}.get(outcome, "[INFO]")
print(f"{self.colorize(prefix, outcome)} {check_name}: {detail}")
def info(self, check_name: str, detail: str) -> None:
print(f"{self.colorize('[INFO]', 'INFO')} {check_name}: {detail}")
def metric(self, name: str, value: Any, unit: str = "", **labels: Any) -> None:
self.metrics.append(Metric(name=name, value=value, unit=unit, labels=labels))
def add_evidence(self, category: str, title: str, path: Path) -> None:
rel = path.relative_to(self.report_dir) if path.is_relative_to(self.report_dir) else path
self.evidence.append(Evidence(category=category, title=title, path=str(rel)))
def write(self, run_id: str, target: str) -> None:
generated_at = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime())
report_timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
status = "失败" if any(r.status == "FAIL" for r in self.report_results()) else "通过"
md_path = self.report_dir / f"report-{report_timestamp}.md"
user_md_path = self.report_dir / f"user-report-{report_timestamp}.md"
md_path.write_text(self.render_markdown(run_id, target, generated_at, status), encoding="utf-8")
user_md_path.write_text(self.render_user_markdown(run_id), encoding="utf-8")
self.info("markdown_report", str(md_path))
self.info("user_markdown_report", str(user_md_path))
def colorize(self, text: str, status: str) -> str:
if not self.use_color:
return text
return f"{STATUS_COLOR.get(status, '')}{text}\033[0m"
def render_markdown(self, run_id: str, target: str, generated_at: str, status: str) -> str:
counts = self.status_counts()
lines = [
"# One-KVM 自动化测试报告",
"",
f"- 运行编号:`{run_id}`",
f"- 目标设备:`{target}`",
f"- 生成时间:`{generated_at}`",
f"- 总体结果:**{status}**",
f"- 统计:通过 {counts.get('PASS', 0)},警告 {counts.get('WARN', 0)},跳过 {counts.get('SKIP', 0)},失败 {counts.get('FAIL', 0)}",
"",
]
grouped = self.group_results()
for category, results in grouped.items():
if not results:
continue
lines.extend([f"## {category}", ""])
lines.extend(self.render_result_table(category, results))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def render_user_markdown(self, run_id: str) -> str:
lines = [
"# One-KVM 测试摘要",
"",
"## 背景",
"",
f"- 运行编号:`{run_id}`",
"- 测试设备:",
f"- 视频设备:{markdown_inline(self.user_video_device())}",
f"- HID设备{markdown_inline(self.user_hid_backend())}",
f"- 网络延迟:{markdown_inline(self.user_network_latency())}",
"",
"## 视频性能",
"",
"| 视频输入参数 | 测试帧率 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max |",
"| --- | --- | --- |",
]
video_rows = self.user_video_rows()
if video_rows:
for params, fps, latency in video_rows:
lines.append(
"| "
+ " | ".join(
(
markdown_table_cell(params),
markdown_table_cell(fps),
markdown_table_cell(latency),
)
)
+ " |"
)
else:
lines.append("| 无数据 | - | - |")
lines.extend(
[
"",
"## HID性能",
"",
"| 输入方式 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max |",
"| --- | --- |",
]
)
hid_rows = self.user_hid_rows()
if hid_rows:
for method, latency in hid_rows:
lines.append(f"| {markdown_table_cell(method)} | {markdown_table_cell(latency)} |")
else:
lines.append("| 无数据 | - |")
lines.extend(
[
"",
"## MSD性能",
"",
"| 操作 | 数据 |",
"| --- | --- |",
]
)
msd_rows = self.user_msd_rows()
if msd_rows:
for operation, data in msd_rows:
lines.append(f"| {markdown_table_cell(operation)} | {markdown_table_cell(data)} |")
else:
lines.append("| 无数据 | - |")
return "\n".join(lines).rstrip() + "\n"
def find_result(self, name: str) -> CheckResult | None:
for result in self.results:
if result.name == name:
return result
return None
def user_network_latency(self) -> str:
result = self.find_result("network_latency")
if not result:
return "无数据"
tcp = result.data.get("tcp_connect") or {}
if tcp.get("samples"):
return format_latency_values(tcp)
return result.message or "无数据"
def user_video_device(self) -> str:
result = self.find_result("video_input_select")
cases = result.data.get("cases") if result else None
if not cases:
return "无数据"
by_device: dict[str, list[str]] = {}
for case in cases:
if not isinstance(case, dict):
continue
device = str(case.get("device") or "未知设备")
by_device.setdefault(device, []).append(format_video_case(case))
return "".join(f"{device}{', '.join(items)}" for device, items in by_device.items()) or "无数据"
def user_hid_backend(self) -> str:
config = self.find_result("hid_msd_config")
if config:
if config.data.get("udc"):
return "OTG"
if config.data.get("port"):
return "CH9329"
status = self.find_result("hid_status")
if status:
data = status.data.get("status") or {}
backend = str(data.get("backend") or data.get("hid_backend") or "").strip()
if backend:
return backend.upper() if backend.lower() == "otg" else backend
return "无数据"
def user_video_rows(self) -> list[tuple[str, str, str]]:
fps_by_key: dict[tuple[str, str], str] = {}
for result in self.results:
if not result.name.startswith("video_") or result.name.startswith("video_latency_"):
continue
data = result.data
case = data.get("case") or {}
if not isinstance(case, dict):
continue
output_mode = str(data.get("codec") or result.name.rsplit("_", 1)[-1]).lower()
label = str(case.get("label") or "")
fps = data.get("fps")
if fps is None:
fps = data.get("avgFps")
if label and output_mode and fps is not None:
fps_by_key[(label, output_mode)] = format_fps(float(fps))
rows: list[tuple[str, str, str]] = []
for result in self.results:
if not result.name.startswith("video_latency_"):
continue
data = result.data
case = data.get("video_case") or {}
if not isinstance(case, dict):
case = {}
output_mode = str(data.get("output_mode") or video_latency_output_from_name(result.name) or "").lower()
label = str(case.get("label") or "")
params = format_video_latency_params(case, output_mode)
fps = fps_by_key.get((label, output_mode), "-")
latency = format_latency_data(data)
if not latency:
latency = f"{STATUS_TEXT.get(result.status, result.status)}{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status)
rows.append((params, fps, latency))
return rows
def user_hid_rows(self) -> list[tuple[str, str]]:
result = self.find_result("hid_latency")
if not result:
return []
latency = format_latency_data(result.data)
if not latency:
latency = f"{STATUS_TEXT.get(result.status, result.status)}{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status)
return [(self.user_hid_backend(), latency)]
def user_msd_rows(self) -> list[tuple[str, str]]:
result = self.find_result("msd")
if not result:
return []
verify = result.data.get("verify") or {}
rows: list[tuple[str, str]] = []
if "write_mib_s" in verify:
rows.append(("", f"{float(verify.get('write_mib_s') or 0):.2f}MiB/s"))
if "read_mib_s" in verify:
rows.append(("", f"{float(verify.get('read_mib_s') or 0):.2f}MiB/s"))
elif "cached_read_mib_s" in verify:
rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s"))
if rows:
return rows
return [("MSD", f"{STATUS_TEXT.get(result.status, result.status)}{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status))]
def report_results(self) -> list[CheckResult]:
return [result for result in self.results if result.name not in REPORT_HIDDEN_RESULTS]
def status_counts(self) -> dict[str, int]:
counts: dict[str, int] = {}
for result in self.report_results():
counts[result.status] = counts.get(result.status, 0) + 1
return counts
def group_results(self) -> dict[str, list[CheckResult]]:
grouped = {name: [] for name, _ in CATEGORY_RULES}
grouped["其他"] = []
for result in self.report_results():
for category, prefixes in CATEGORY_RULES:
if any(result.name == prefix or result.name.startswith(prefix) for prefix in prefixes):
grouped[category].append(result)
break
else:
grouped["其他"].append(result)
return grouped
def render_result(self, result: CheckResult) -> list[str]:
title = display_name(result.name)
status = STATUS_TEXT.get(result.status, result.status)
lines = [f"### {title}", "", f"- 结果:**{status}**"]
summary = summarize_result(result, self.metrics)
if summary:
lines.append(f"- 数据:{summary}")
if result.status == "FAIL":
lines.append(f"- 失败日志:`{markdown_inline(result.message)}`")
elif result.status in {"WARN", "SKIP"} and result.message:
lines.append(f"- 说明:{markdown_inline(result.message)}")
return lines
def render_result_table(self, category: str, results: list[CheckResult]) -> list[str]:
with_data = category in CATEGORIES_WITH_DATA_TABLE or (
category not in CATEGORIES_WITHOUT_DATA_TABLE
and any(has_numeric_data(result, self.metrics) for result in results)
)
if with_data:
lines = ["| 项目 | 结果 | 数据 |", "| --- | --- | --- |"]
for result in results:
lines.append(
"| "
+ " | ".join(
(
markdown_table_cell(display_name(result.name)),
markdown_table_cell(STATUS_TEXT.get(result.status, result.status)),
markdown_table_cell(summarize_result(result, self.metrics)),
)
)
+ " |"
)
else:
lines = ["| 项目 | 结果 |", "| --- | --- |"]
for result in results:
lines.append(
"| "
+ " | ".join(
(
markdown_table_cell(display_name(result.name)),
markdown_table_cell(STATUS_TEXT.get(result.status, result.status)),
)
)
+ " |"
)
sections = (
("失败项目", [result for result in results if result.status == "FAIL"]),
("跳过项目", [result for result in results if result.status == "SKIP"]),
("警告项目", [result for result in results if result.status == "WARN"]),
)
for heading, items in sections:
if not items:
continue
lines.extend(["", f"{heading}"])
for result in items:
title = display_name(result.name)
status = STATUS_TEXT.get(result.status, result.status)
reason = result.message or summarize_result(result, self.metrics) or "无详细日志"
lines.append(f"- **{markdown_inline(title)}**{status}{markdown_inline(reason)}")
return lines
def summarize_result(result: CheckResult, metrics: list[Metric]) -> str:
data = result.data
name = result.name
if name == "network_latency":
tcp = data.get("tcp_connect") or {}
http = data.get("http_health") or {}
parts = []
if tcp.get("samples"):
parts.append(
f"TCP p50={float(tcp.get('p50_ms', 0)):.1f}msp95={float(tcp.get('p95_ms', 0)):.1f}msmax={float(tcp.get('max_ms', 0)):.1f}ms"
)
if http.get("samples"):
parts.append(
f"HTTP p50={float(http.get('p50_ms', 0)):.1f}msp95={float(http.get('p95_ms', 0)):.1f}msmax={float(http.get('max_ms', 0)):.1f}ms"
)
errors = data.get("errors") or []
if errors:
parts.append(f"异常样本={len(errors)}")
return "".join(parts)
if name.startswith("config_video_") and isinstance(data.get("case"), dict):
case = data["case"]
return f"{case.get('device')} {case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}"
if name.startswith("video_latency_") or name == "hdmi_latency_mjpeg":
if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")):
return ""
case = data.get("video_case") or {}
output_mode = str(data.get("output_mode") or video_latency_output_from_name(name) or "").upper()
parts = []
if output_mode:
parts.append(output_mode)
if isinstance(case, dict) and case.get("fmt"):
parts.append(f"{case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}")
parts.extend(
[
f"p50={float(data.get('p50_ms', 0)):.1f}ms",
f"p95={float(data.get('p95_ms', 0)):.1f}ms",
f"max={float(data.get('max_ms', 0)):.1f}ms",
]
)
return "".join(parts)
if name.startswith("video_"):
parts = []
if "fps" in data:
parts.append(f"fps={float(data['fps']):.1f}")
if "avgFps" in data:
parts.append(f"平均fps={float(data['avgFps']):.1f}")
if "input_requested_fps" in data:
parts.append(f"请求输入={float(data['input_requested_fps']):.1f}fps")
if "maxRtt" in data:
parts.append(f"最大RTT={float(data['maxRtt']) * 1000:.1f}ms")
if "min_expected_fps" in data:
parts.append(f"目标下限={float(data['min_expected_fps']):.1f}fps")
if "dynamic_source_fps" in data:
parts.append(f"动态源={int(data['dynamic_source_fps'])}fps")
if data.get("fps_above_requested"):
parts.append("输出帧率高于请求输入")
if data.get("unsupported"):
parts.append("不支持")
return "".join(parts)
if name == "hdmi_identity":
samples = data.get("samples") or []
ok = sum(1 for item in samples if item.get("identity_match"))
parts = [f"颜色匹配 {ok}/{len(samples)}"]
if samples:
detail = []
for item in samples:
rgb = item.get("measured_rgb_mean")
status = "匹配" if item.get("identity_match") else "不匹配"
detail.append(f"{item.get('name')}={rgb}({status})")
parts.append("".join(detail))
return "".join(parts)
if name == "hdmi_color_range":
samples = data.get("samples") or []
if not samples:
return result.message
worst = max(samples, key=lambda item: float(item.get("mean_abs_error") or 0))
missed = sum(1 for item in samples if not item.get("target_detected"))
max_mean_error = max((float(item.get("mean_abs_error") or 0) for item in samples), default=0.0)
max_abs_error = max((float(item.get("max_abs_error") or 0) for item in samples), default=0.0)
return (
f"最大平均误差={max_mean_error:.1f},最大通道误差={max_abs_error:.1f}"
f"未命中颜色={missed}/{len(samples)}"
f"最差={worst.get('name')} 实测RGB={worst.get('measured_rgb_mean')}"
f"采样帧={worst.get('frames_seen', 0)}"
)
if name == "hid_input":
count = int(data.get("event_count") or len(data.get("events") or []))
keyboard = data.get("keyboard") or {}
mouse = data.get("mouse") or {}
parts = [
f"捕获事件数={count}",
f"字母数字={keyboard.get('alphanumeric_tested', 0)}",
f"功能键={keyboard.get('function_keys_tested', 0)}",
f"安全组合键={keyboard.get('safe_combos_tested', 0)}",
f"绝对鼠标={'通过' if mouse.get('absolute_ok') else '失败'}",
f"相对鼠标={'通过' if mouse.get('relative_ok') else '失败'}",
]
missing = []
for key in (
"missing_alphanumeric_down",
"missing_alphanumeric_up",
"missing_function_down",
"missing_function_up",
"missing_combos",
):
values = keyboard.get(key) or []
if values:
missing.append(f"{key}={','.join(map(str, values[:10]))}")
if missing:
parts.append("缺失:" + "".join(missing))
return "".join(parts)
if name == "hid_latency":
if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")):
return ""
parts = [
f"按键={data.get('key', '')}",
f"p50={float(data.get('p50_ms', 0)):.1f}ms",
f"p95={float(data.get('p95_ms', 0)):.1f}ms",
f"max={float(data.get('max_ms', 0)):.1f}ms",
]
missing = int(data.get("missing_trials") or 0)
if missing:
parts.append(f"未捕获={missing}")
return "".join(parts)
if name == "msd":
verify = data.get("verify") or {}
size = verify.get("size_bytes")
if not size:
return ""
parts = [f"读写校验大小={size} bytes"]
if "write_mib_s" in verify:
parts.append(f"写入={float(verify.get('write_mib_s') or 0):.2f}MiB/s")
if "read_mib_s" in verify:
parts.append(f"未缓存读取={float(verify.get('read_mib_s') or 0):.2f}MiB/s")
elif "cached_read_mib_s" in verify:
parts.append(f"缓存读取={float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s仅校验")
if "write_ms" in verify and "read_ms" in verify:
parts.append(f"写耗时={float(verify.get('write_ms') or 0):.1f}ms读耗时={float(verify.get('read_ms') or 0):.1f}ms")
if verify.get("read_cached"):
parts.append("读速未作为真实盘读速")
return "".join(parts)
if name == "atx_api":
return "status/config/WOL API 均有响应"
if name == "windows_agent":
hello = data.get("hello") or {}
return f"主机={hello.get('hostname', 'unknown')}"
if name == "target_inventory":
return "已采集 lsusb、uname、服务状态"
if name == "target_reset":
return "数据库已备份,服务已重启"
if name == "setup_init":
return "初始化检查完成"
if name in {"login", "login_after_restart"}:
return "认证成功"
if name == "hid_msd_config":
return "已按设备能力配置 HID/MSD"
if name == "ventoy_resources":
return "Ventoy 资源检查完成"
if name == "msd_restart":
return "服务重启完成"
if name == "video_input_select":
cases = data.get("cases") or []
return "".join(f"{c.get('fmt')} {c.get('width')}x{c.get('height')}@{c.get('fps')}" for c in cases if isinstance(c, dict))
return ""
def display_name(name: str) -> str:
if name.startswith("video_latency_"):
body = name.removeprefix("video_latency_")
for mode in ("mjpeg", "h264", "h265"):
suffix = f"_{mode}"
if body == mode:
return f"视频延迟 {mode.upper()}"
if body.endswith(suffix):
label = body[: -len(suffix)]
return f"视频延迟 {label} {mode.upper()}"
return f"视频延迟 {body}"
return DISPLAY_NAMES.get(name, name)
def format_video_case(case: dict[str, Any]) -> str:
fmt = str(case.get("fmt") or "").lower()
resolution = format_resolution(case)
fps = format_fps_value(case.get("fps"))
return " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part)
def format_video_latency_params(case: dict[str, Any], output_mode: str) -> str:
fmt = str(case.get("fmt") or "").lower()
output = output_mode.lower() if output_mode else "unknown"
resolution = format_resolution(case)
fps = format_fps_value(case.get("fps"))
input_part = " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part)
return f"{input_part}-->{output}" if input_part else output
def format_resolution(case: dict[str, Any]) -> str:
try:
width = int(case.get("width") or 0)
height = int(case.get("height") or 0)
except Exception:
return ""
if height > 0 and width in {1280, 1920, 2560, 3840, 4096}:
return f"{height}p"
if width > 0 and height > 0:
return f"{width}x{height}"
return ""
def format_fps(value: float) -> str:
return f"{value:.0f}fps" if abs(value - round(value)) < 0.05 else f"{value:.1f}fps"
def format_fps_value(value: Any) -> str:
try:
return format_fps(float(value))
except Exception:
return ""
def format_latency_data(data: dict[str, Any]) -> str:
if not any(key in data for key in ("p50_ms", "p95_ms", "max_ms")):
return ""
return format_latency_values(data)
def format_latency_values(data: dict[str, Any]) -> str:
return (
f"p50={float(data.get('p50_ms', 0)):.1f}ms"
f"p95={float(data.get('p95_ms', 0)):.1f}ms"
f"max={float(data.get('max_ms', 0)):.1f}ms"
)
def video_latency_output_from_name(name: str) -> str:
body = name.removeprefix("video_latency_") if name.startswith("video_latency_") else name
for mode in ("mjpeg", "h264", "h265"):
if body == mode or body.endswith(f"_{mode}"):
return mode
return ""
def has_numeric_data(result: CheckResult, metrics: list[Metric]) -> bool:
summary = summarize_result(result, metrics)
return any(ch.isdigit() for ch in summary)
def markdown_inline(value: str) -> str:
return str(value).replace("\n", " | ").replace("`", "'")
def markdown_table_cell(value: str) -> str:
return markdown_inline(value).replace("|", "\\|")
def markdown_link(path: str) -> str:
return path.replace(" ", "%20")

2319
test/okvm-test/okvm_testctl.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
httpx>=0.27
paramiko>=3.4
websockets>=12
playwright>=1.45
Pillow>=10