mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 06:31:45 +08:00
test: 增加测试套件
This commit is contained in:
4
test/okvm-test/.gitignore
vendored
Normal file
4
test/okvm-test/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
reports/
|
||||||
|
bin/*.exe
|
||||||
187
test/okvm-test/README.md
Normal file
187
test/okvm-test/README.md
Normal 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. 控制端断开 MSD,Windows 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` 忽略。
|
||||||
12
test/okvm-test/agent/build-windows.sh
Executable file
12
test/okvm-test/agent/build-windows.sh
Executable 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"
|
||||||
5
test/okvm-test/agent/go.mod
Normal file
5
test/okvm-test/agent/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module okvm-win-agent
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3
|
||||||
2
test/okvm-test/agent/go.sum
Normal file
2
test/okvm-test/agent/go.sum
Normal 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
1202
test/okvm-test/agent/main.go
Normal file
File diff suppressed because it is too large
Load Diff
79
test/okvm-test/okvm_media.py
Normal file
79
test/okvm-test/okvm_media.py
Normal 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
|
||||||
671
test/okvm-test/okvm_report.py
Normal file
671
test/okvm-test/okvm_report.py
Normal 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}ms,p95={float(tcp.get('p95_ms', 0)):.1f}ms,max={float(tcp.get('max_ms', 0)):.1f}ms"
|
||||||
|
)
|
||||||
|
if http.get("samples"):
|
||||||
|
parts.append(
|
||||||
|
f"HTTP p50={float(http.get('p50_ms', 0)):.1f}ms,p95={float(http.get('p95_ms', 0)):.1f}ms,max={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
2319
test/okvm-test/okvm_testctl.py
Executable file
File diff suppressed because it is too large
Load Diff
5
test/okvm-test/requirements.txt
Normal file
5
test/okvm-test/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
httpx>=0.27
|
||||||
|
paramiko>=3.4
|
||||||
|
websockets>=12
|
||||||
|
playwright>=1.45
|
||||||
|
Pillow>=10
|
||||||
Reference in New Issue
Block a user