Add eval_kit evaluation suite with WebRTC visualization
- eval_kit/core: 零框架依赖的指标内核(Trace/metrics/aggregation/terrain_spec/report/naming) - eval_kit/isaaclab: 适配层(env_cfg 改造、rollout 采集、checkpoint 加载) - eval_kit/profiles.py / protocols.py: 机器人形态描述与评估口径预设 - eval_kit/tests: 24 项内核自检 + 21 项命令行自检(都不需要仿真) - eval_suite.py: 编排入口,每个(阶段, 种子)一个子进程;与 eval.py 指标口径一致 - WebRTC 可视化:--livestream 透传、视口相机跟随/自由(--view-env / --free-camera)、 连接前停顿(--pause-s)、自动放开客户端改画幅、打印连接地址 - 诊断与替代路径:rollout 进度打印、--record-frames 抓帧
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""命令行入口的静态自检:不需要 Isaac Sim,也不需要 GPU。
|
||||
|
||||
用桩替换 ``isaaclab.app``,从而可以单独加载 ``eval_suite.py``,检查参数解析与
|
||||
口径覆盖逻辑。仿真相关的部分(建环境、跑 rollout)不在覆盖范围内——那些必须真跑。
|
||||
|
||||
运行方式(在仓库根目录)::
|
||||
|
||||
python3 eval_kit/tests/test_cli.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
EVAL_SUITE_PATH = PROJECT_ROOT / "scripts" / "reinforcement_learning" / "rsl_rl" / "eval_suite.py"
|
||||
|
||||
#: ``AppLauncher.add_app_launcher_args()`` 会添加的参数名,抄自
|
||||
#: ``IsaacLab-2.3.2/source/isaaclab/isaaclab/app/app_launcher.py``。
|
||||
#: 自己的 parser 里出现任何一个都会让 argparse 抛
|
||||
#: ``ArgumentError: conflicting option string``,脚本连启动都做不到。
|
||||
#: (只有 device / headless / enable_cameras / experience / livestream /
|
||||
#: rendering_mode / xr 会被 AppLauncher 自己的显式检查拦下,--verbose / --info /
|
||||
#: --cpu 等则由 argparse 拦,所以必须把 14 个都列出来。)
|
||||
APPLAUNCHER_ARG_NAMES = (
|
||||
"--headless",
|
||||
"--livestream",
|
||||
"--enable_cameras",
|
||||
"--xr",
|
||||
"--device",
|
||||
"--cpu",
|
||||
"--verbose",
|
||||
"--info",
|
||||
"--experience",
|
||||
"--rendering_mode",
|
||||
"--kit_args",
|
||||
"--anim_recording_enabled",
|
||||
"--anim_recording_start_time",
|
||||
"--anim_recording_stop_time",
|
||||
)
|
||||
|
||||
|
||||
def _stub_isaaclab() -> None:
|
||||
"""装一个最小的 isaaclab.app 桩,只为让模块级 import 通过。
|
||||
|
||||
桩要复刻真实 ``AppLauncher`` 的行为,否则测试放过的正是真跑会崩的地方:
|
||||
|
||||
- ``add_app_launcher_args`` 会添加上面 14 个参数名
|
||||
- 如果 parser 里已经有同名参数,它**拒绝启动**而不是静默覆盖
|
||||
"""
|
||||
if "isaaclab.app" in sys.modules:
|
||||
return
|
||||
|
||||
class AppLauncher: # noqa: D401 - 桩
|
||||
def __init__(self, args):
|
||||
self.app = None
|
||||
|
||||
@staticmethod
|
||||
def add_app_launcher_args(parser):
|
||||
existing = {option for action in parser._actions for option in action.option_strings}
|
||||
conflict = sorted(existing & set(APPLAUNCHER_ARG_NAMES))
|
||||
if conflict:
|
||||
raise ValueError(
|
||||
f"argument {conflict[0]}: conflicting option string: {conflict[0]}"
|
||||
)
|
||||
parser.add_argument("--headless", action="store_true", default=False)
|
||||
parser.add_argument("--device", type=str, default=None)
|
||||
for name in APPLAUNCHER_ARG_NAMES:
|
||||
dest = name.lstrip("-").replace("-", "_")
|
||||
if dest in ("headless", "device"):
|
||||
continue
|
||||
parser.add_argument(name, dest=dest, nargs="?", const=True, default=None)
|
||||
return parser
|
||||
|
||||
isaaclab = types.ModuleType("isaaclab")
|
||||
app = types.ModuleType("isaaclab.app")
|
||||
app.AppLauncher = AppLauncher # type: ignore[attr-defined]
|
||||
isaaclab.app = app # type: ignore[attr-defined]
|
||||
sys.modules["isaaclab"] = isaaclab
|
||||
sys.modules["isaaclab.app"] = app
|
||||
|
||||
|
||||
def _load_eval_suite():
|
||||
_stub_isaaclab()
|
||||
specification = importlib.util.spec_from_file_location("eval_suite_under_test", EVAL_SUITE_PATH)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError(f"无法加载 {EVAL_SUITE_PATH}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_parser_defaults():
|
||||
"""默认值:协议预设名、机器人描述名、评估类型都在。"""
|
||||
module = _load_eval_suite()
|
||||
args = module._build_parser().parse_args([])
|
||||
|
||||
assert args.task == "Rough-Deeprobotics-M20-v0", args.task
|
||||
assert args.profile == "solenopsis"
|
||||
assert args.protocol == "standard_v1"
|
||||
assert args.eval_type == "all"
|
||||
assert args.num_envs == 250
|
||||
assert args.terrain_type == "all"
|
||||
# 口径覆盖项默认必须为 None,否则会静默覆盖协议预设
|
||||
for name in (
|
||||
"episode_length_s", "command_x", "command_y", "command_yaw", "start_x", "start_y",
|
||||
"terrain_size", "success_distance", "tilt_threshold", "acc_threshold_g",
|
||||
"energy_convention", "robot_mass", "num_levels", "envs_per_cell", "seeds",
|
||||
"enforce_terrain_bounds", "start_z_offset",
|
||||
):
|
||||
assert getattr(args, name) is None, f"{name} 默认应为 None,实际 {getattr(args, name)!r}"
|
||||
return "默认值正确,口径覆盖项默认全部为 None"
|
||||
|
||||
|
||||
def test_no_terrain_bounds_flag():
|
||||
"""--no-terrain-bounds 关闭越界终止项,并改变指纹。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
preset = get_protocol("standard_v1")
|
||||
assert preset.enforce_terrain_bounds is True, "standard_v1 应保持与 eval.py 一致(保留终止项)"
|
||||
|
||||
args = module._build_parser().parse_args(["--no-terrain-bounds"])
|
||||
protocol = module._apply_overrides(preset, args)
|
||||
assert protocol.enforce_terrain_bounds is False
|
||||
assert protocol.fingerprint() != preset.fingerprint(), "关闭终止项必须改变指纹"
|
||||
|
||||
# 不带该开关时不覆盖
|
||||
unchanged = module._apply_overrides(preset, module._build_parser().parse_args([]))
|
||||
assert unchanged.enforce_terrain_bounds is True
|
||||
return "开关生效且反映在指纹里"
|
||||
|
||||
|
||||
def test_clean_protocol_uses_big_tile():
|
||||
"""standard_v1_clean 通过放大地砖来消除越界误判,而不是关掉终止项。"""
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
clean = get_protocol("standard_v1_clean")
|
||||
parity = get_protocol("standard_v1")
|
||||
no_bounds = get_protocol("standard_v1_no_bounds")
|
||||
|
||||
assert clean.terrain_size_m >= 3.8, f"地砖 {clean.terrain_size_m} 不足以消除误判"
|
||||
assert clean.enforce_terrain_bounds is True, "clean 方案应保留终止项"
|
||||
assert no_bounds.enforce_terrain_bounds is False
|
||||
# 除地砖外其余口径与 parity 方案一致,数值仍可对照
|
||||
assert clean.command == parity.command
|
||||
assert clean.success_distance_m == parity.success_distance_m
|
||||
assert clean.num_levels == parity.num_levels
|
||||
return f"clean 地砖 {clean.terrain_size_m} m,保留终止项;no_bounds 关闭终止项"
|
||||
|
||||
|
||||
def test_overrides_are_opt_in():
|
||||
"""不传任何覆盖项时,协议必须与预设逐字段相同。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
args = module._build_parser().parse_args([])
|
||||
preset = get_protocol("standard_v1")
|
||||
protocol = module._apply_overrides(preset, args)
|
||||
|
||||
assert protocol == preset, "未传覆盖项却改变了协议"
|
||||
assert protocol.fingerprint() == preset.fingerprint()
|
||||
return "未传覆盖 → 协议与预设完全相同(含 fingerprint)"
|
||||
|
||||
|
||||
def test_overrides_apply_and_update_fingerprint():
|
||||
"""显式覆盖项生效,且指纹随之改变。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
args = module._build_parser().parse_args(
|
||||
[
|
||||
"--episode-length-s", "20",
|
||||
"--command-x", "0.8",
|
||||
"--command-yaw", "-0.3",
|
||||
"--success-distance", "3.5",
|
||||
"--tilt-threshold", "0.5",
|
||||
"--acc-threshold-g", "0.3",
|
||||
"--energy-convention", "signed",
|
||||
"--terrain-size", "1.5",
|
||||
"--num-levels", "4",
|
||||
"--envs-per-cell", "2",
|
||||
"--seeds", "7", "8",
|
||||
]
|
||||
)
|
||||
preset = get_protocol("standard_v1")
|
||||
protocol = module._apply_overrides(preset, args)
|
||||
|
||||
assert protocol.steps == 1000, protocol.steps # 20 s / 0.02 s
|
||||
assert protocol.command == (0.8, 0.0, -0.3), protocol.command
|
||||
assert protocol.success_distance_m == 3.5
|
||||
assert protocol.tilt_threshold_rad == 0.5
|
||||
assert abs(protocol.acc_threshold_g - 0.3) < 1e-12
|
||||
assert protocol.energy_convention == "signed"
|
||||
assert protocol.terrain_size_m == 1.5
|
||||
assert protocol.num_levels == 4
|
||||
assert protocol.envs_per_cell == 2
|
||||
assert protocol.seeds == (7, 8)
|
||||
assert protocol.fingerprint() != preset.fingerprint()
|
||||
# 没覆盖的字段保持预设
|
||||
assert protocol.start_pose == preset.start_pose
|
||||
assert protocol.dt == preset.dt
|
||||
return "11 项覆盖全部生效,未覆盖字段保持预设"
|
||||
|
||||
|
||||
def test_command_overrides_compose_independently():
|
||||
"""单独覆盖某一个速度分量时,另两个分量保持预设。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
preset = get_protocol("standard_v1") # command = (1.0, 0.0, 0.0)
|
||||
|
||||
only_y = module._apply_overrides(preset, module._build_parser().parse_args(["--command-y", "0.5"]))
|
||||
assert only_y.command == (1.0, 0.5, 0.0), only_y.command
|
||||
|
||||
only_yaw = module._apply_overrides(preset, module._build_parser().parse_args(["--command-yaw", "1.2"]))
|
||||
assert only_yaw.command == (1.0, 0.0, 1.2), only_yaw.command
|
||||
|
||||
both = module._apply_overrides(
|
||||
preset, module._build_parser().parse_args(["--command-y", "0.5", "--command-yaw", "1.2"])
|
||||
)
|
||||
assert both.command == (1.0, 0.5, 1.2), both.command
|
||||
|
||||
only_x = module._apply_overrides(preset, module._build_parser().parse_args(["--command-x", "2.0"]))
|
||||
assert only_x.command == (2.0, 0.0, 0.0), only_x.command
|
||||
return "command 三个分量可独立覆盖"
|
||||
|
||||
|
||||
def test_start_pose_overrides_compose():
|
||||
"""起始位姿同理。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
preset = get_protocol("standard_v1")
|
||||
protocol = module._apply_overrides(
|
||||
preset, module._build_parser().parse_args(["--start-x", "-1.2", "--start-y", "0.4"])
|
||||
)
|
||||
assert protocol.start_pose == (-1.2, 0.4, 0.0), protocol.start_pose
|
||||
return f"start_pose = {protocol.start_pose}"
|
||||
|
||||
|
||||
def test_start_z_offset_override():
|
||||
"""起始高度偏移:默认 0.0(与旧 eval.py 一致),显式给出时生效并改变指纹。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.protocols import get_protocol
|
||||
|
||||
preset = get_protocol("standard_v1")
|
||||
assert preset.start_z_offset_m == 0.0, "默认必须为 0.0,否则与 eval.py 不可比"
|
||||
|
||||
parser = module._build_parser()
|
||||
unchanged = module._apply_overrides(preset, parser.parse_args([]))
|
||||
assert unchanged.start_z_offset_m == 0.0
|
||||
assert unchanged.fingerprint() == preset.fingerprint()
|
||||
|
||||
lifted = module._apply_overrides(preset, parser.parse_args(["--start-z-offset", "0.3"]))
|
||||
assert lifted.start_z_offset_m == 0.3
|
||||
assert lifted.fingerprint() != preset.fingerprint(), "抬高起始高度必须改变指纹"
|
||||
# 只动高度,不动水平位姿
|
||||
assert lifted.start_pose == preset.start_pose
|
||||
return "默认 0.0,--start-z-offset 生效且反映在指纹里"
|
||||
|
||||
|
||||
def test_terrain_types_resolution():
|
||||
"""地形类型:all 展开成规格里的全部类型,单个类型要校验存在性。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.core.terrain_spec import get_terrain_spec
|
||||
|
||||
spec = get_terrain_spec("standard_v1")
|
||||
|
||||
all_args = module._build_parser().parse_args([])
|
||||
assert module._terrain_types_for(all_args, spec) == list(spec.types)
|
||||
|
||||
single_args = module._build_parser().parse_args(["--terrain-type", "boxes"])
|
||||
assert module._terrain_types_for(single_args, spec) == ["boxes"]
|
||||
|
||||
bad_args = module._build_parser().parse_args(["--terrain-type", "nonexistent"])
|
||||
try:
|
||||
module._terrain_types_for(bad_args, spec)
|
||||
except SystemExit as exc:
|
||||
assert "nonexistent" in str(exc)
|
||||
return "all / 单类型 / 非法类型校验都正确"
|
||||
raise AssertionError("非法地形类型没有报错")
|
||||
|
||||
|
||||
def test_all_profiles_and_protocols_are_loadable():
|
||||
"""每个机器人描述、每个协议预设都要能被命令行选中。"""
|
||||
module = _load_eval_suite()
|
||||
parser = module._build_parser()
|
||||
from eval_kit.profiles import PROFILES
|
||||
from eval_kit.protocols import PROTOCOLS
|
||||
|
||||
for name in PROFILES:
|
||||
args = parser.parse_args(["--profile", name])
|
||||
assert args.profile == name
|
||||
for name in PROTOCOLS:
|
||||
args = parser.parse_args(["--protocol", name])
|
||||
protocol = module._apply_overrides(PROTOCOLS[name], args)
|
||||
assert protocol.steps >= 1
|
||||
assert protocol.seeds, f"协议 {name} 没有种子"
|
||||
return f"{len(PROFILES)} 个描述、{len(PROTOCOLS)} 个协议预设都可加载"
|
||||
|
||||
|
||||
def test_eval_types_match_report_module():
|
||||
"""命令行的评估类型必须与指标筛选表一致,避免出现选了却没指标的项。"""
|
||||
module = _load_eval_suite()
|
||||
from eval_kit.core.report import EVAL_TYPES, METRIC_SELECTION
|
||||
|
||||
args = module._build_parser().parse_args([])
|
||||
choices = args.eval_type
|
||||
assert choices in EVAL_TYPES
|
||||
for eval_type in EVAL_TYPES:
|
||||
if eval_type in ("all", "upper_limits"):
|
||||
continue
|
||||
assert eval_type in METRIC_SELECTION, f"{eval_type} 没有对应的指标筛选规则"
|
||||
return f"{len(EVAL_TYPES)} 个评估类型,与 METRIC_SELECTION 对齐"
|
||||
|
||||
|
||||
def test_parser_compatible_with_app_launcher():
|
||||
"""自身的参数不能和 AppLauncher 冲突。
|
||||
|
||||
main() 的顺序是「先 _build_parser(),再 AppLauncher.add_app_launcher_args()」。
|
||||
这里复刻同样的顺序:任何冲突都会在这一步抛错,也就是脚本连启动都做不到。
|
||||
真跑时曾两次撞上这个坑(先是 --device、后是 --verbose),所以这个检查必须留下。
|
||||
"""
|
||||
module = _load_eval_suite()
|
||||
parser = module._build_parser()
|
||||
parser = module.AppLauncher.add_app_launcher_args(parser)
|
||||
args = parser.parse_args([])
|
||||
|
||||
# --device / --headless 由 AppLauncher 提供,自己的 parser 里不能再定义
|
||||
assert args.device is None
|
||||
assert args.headless is False
|
||||
assert args.task == "Rough-Deeprobotics-M20-v0"
|
||||
assert args.num_envs == 250
|
||||
return "与 AppLauncher 的 14 个参数名无冲突,--device/--headless 由其提供"
|
||||
|
||||
|
||||
def test_no_option_string_collisions_with_app_launcher():
|
||||
"""直接枚举检查:自身 parser 的任何 option string 都不能落在 AppLauncher 的名单里。"""
|
||||
module = _load_eval_suite()
|
||||
parser = module._build_parser()
|
||||
own = {option for action in parser._actions for option in action.option_strings}
|
||||
conflicts = sorted(own & set(APPLAUNCHER_ARG_NAMES))
|
||||
assert not conflicts, (
|
||||
f"与 AppLauncher 冲突: {conflicts} —— 真实启动时会抛 "
|
||||
"argparse.ArgumentError: conflicting option string"
|
||||
)
|
||||
return f"自身 {len(own)} 个 option string 与 AppLauncher 的 {len(APPLAUNCHER_ARG_NAMES)} 个均不冲突"
|
||||
|
||||
|
||||
def test_verbose_terrain_flag_replaces_verbose():
|
||||
"""--verbose 归 AppLauncher,自己的逐格调试用 --verbose-terrain。"""
|
||||
module = _load_eval_suite()
|
||||
parser = module._build_parser()
|
||||
args = parser.parse_args(["--verbose-terrain"])
|
||||
assert args.verbose_terrain is True
|
||||
plain = parser.parse_args([])
|
||||
assert plain.verbose_terrain is False
|
||||
return "--verbose-terrain 生效,未占用 --verbose"
|
||||
|
||||
|
||||
def test_output_path_defaults_next_to_checkpoint():
|
||||
"""默认输出落在 checkpoint 同目录的 eval_kit/ 下,不覆盖 eval.py 的 eval/。"""
|
||||
module = _load_eval_suite()
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkpoint = str(Path(directory) / "model_100.pt")
|
||||
Path(checkpoint).write_bytes(b"")
|
||||
args = module._build_parser().parse_args([])
|
||||
path = module._resolve_output_path(args, checkpoint, "all")
|
||||
|
||||
assert "/eval_kit/" in path, path
|
||||
assert path.endswith(".json")
|
||||
assert "all_" in Path(path).name
|
||||
return f"默认输出目录为 <checkpoint 目录>/eval_kit/"
|
||||
|
||||
|
||||
def test_livestream_value_falls_back_to_env_var():
|
||||
"""--livestream 默认 -1 表示「听 LIVESTREAM 环境变量」,不能当成 0。"""
|
||||
import os
|
||||
|
||||
module = _load_eval_suite()
|
||||
parser = module._build_parser()
|
||||
|
||||
args = parser.parse_args([])
|
||||
args.livestream = -1
|
||||
previous = os.environ.get("LIVESTREAM")
|
||||
os.environ.pop("LIVESTREAM", None)
|
||||
try:
|
||||
assert module._livestream_value(args) == 0, "没设环境变量时应视为关闭"
|
||||
os.environ["LIVESTREAM"] = "2"
|
||||
assert module._livestream_value(args) == 2, "应回退读 LIVESTREAM 环境变量"
|
||||
|
||||
explicit = parser.parse_args([])
|
||||
explicit.livestream = 1
|
||||
assert module._livestream_value(explicit) == 1, "命令行显式值优先于环境变量"
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("LIVESTREAM", None)
|
||||
else:
|
||||
os.environ["LIVESTREAM"] = previous
|
||||
return "命令行 > LIVESTREAM 环境变量 > 关闭"
|
||||
|
||||
|
||||
def test_viewer_env_index_covers_livestream():
|
||||
"""推流也要设视口相机;真 headless(且不推流)才返回 None。
|
||||
|
||||
这条决定推流画面能不能看到机器人:``AppLauncher`` 在开启推流时会把
|
||||
``headless`` 改写成 True,所以不能按 ``headless`` 判断「用户要不要看画面」。
|
||||
"""
|
||||
module = _load_eval_suite()
|
||||
# --headless 由 AppLauncher 提供,所以这里要按 main() 的顺序加上
|
||||
parser = module.AppLauncher.add_app_launcher_args(module._build_parser())
|
||||
|
||||
plain_headless = parser.parse_args(["--headless"])
|
||||
plain_headless.livestream = 0
|
||||
assert module._viewer_env_index(plain_headless) is None, "headless 且不推流时不应设视口相机"
|
||||
|
||||
streamed = parser.parse_args(["--headless", "--view-env", "7"])
|
||||
streamed.livestream = 2
|
||||
assert module._viewer_env_index(streamed) == 7, "推流时视口相机应生效并跟随 --view-env"
|
||||
|
||||
default_target = parser.parse_args([])
|
||||
default_target.livestream = 2
|
||||
assert module._viewer_env_index(default_target) == 0, "不传 --view-env 时默认跟随 env 0"
|
||||
|
||||
gui = parser.parse_args([])
|
||||
gui.livestream = 0
|
||||
assert module._viewer_env_index(gui) == 0, "本地 GUI(非 headless)仍然跟随 env 0"
|
||||
|
||||
# 自由视角:不跟随,且 --free-camera 优先于 --view-env
|
||||
free = parser.parse_args(["--headless", "--free-camera"])
|
||||
free.livestream = 2
|
||||
assert module._viewer_env_index(free) is None, "--free-camera 时不应跟随任何环境"
|
||||
both = parser.parse_args(["--headless", "--free-camera", "--view-env", "3"])
|
||||
both.livestream = 2
|
||||
assert module._viewer_env_index(both) is None, "--free-camera 与 --view-env 同时给出时以前者为准"
|
||||
return "headless→None;推流 / 本地 GUI→跟随 --view-env(默认 0);--free-camera 优先于跟随"
|
||||
|
||||
|
||||
def test_free_camera_only_applies_with_a_picture():
|
||||
"""--free-camera 只在有画面时生效;给定 --view-env 也让位于它。"""
|
||||
module = _load_eval_suite()
|
||||
parser = module.AppLauncher.add_app_launcher_args(module._build_parser())
|
||||
|
||||
headless = parser.parse_args(["--headless", "--free-camera"])
|
||||
headless.livestream = 0
|
||||
assert module._display_enabled(headless) is False, "headless 且不推流时没有画面"
|
||||
assert module._viewer_env_index(headless) is None
|
||||
|
||||
streamed = parser.parse_args(["--free-camera"])
|
||||
streamed.livestream = 2
|
||||
assert module._display_enabled(streamed) is True, "推流算有画面"
|
||||
assert module._viewer_env_index(streamed) is None, "有画面 + --free-camera → 不跟随"
|
||||
|
||||
# 显式给出 --view-env 但没有 --free-camera:仍然跟随
|
||||
following = parser.parse_args(["--view-env", "2"])
|
||||
following.livestream = 2
|
||||
assert module._viewer_env_index(following) == 2
|
||||
return "无画面时 --free-camera 无效果;有画面时不跟随;未给该参数时行为不变"
|
||||
|
||||
|
||||
def test_pause_defaults_to_connection_window_only_when_streaming():
|
||||
"""--pause-s 是给推流客户端留连接时间的,默认值在 main() 里按是否推流决定。"""
|
||||
module = _load_eval_suite()
|
||||
# --livestream 由 AppLauncher 提供,所以这里要按 main() 的顺序加上
|
||||
parser = module.AppLauncher.add_app_launcher_args(module._build_parser())
|
||||
|
||||
assert parser.parse_args([]).pause_s is None, "默认必须是 None,否则区分不出用户显式传的 0"
|
||||
assert parser.parse_args(["--pause-s", "0"]).pause_s == 0.0
|
||||
assert parser.parse_args(["--pause-s", "12.5"]).pause_s == 12.5
|
||||
|
||||
def resolve(arguments):
|
||||
"""复刻 main() 里的取值规则。"""
|
||||
parsed = parser.parse_args(arguments)
|
||||
streaming = module._livestream_value(parsed) > 0
|
||||
return parsed.pause_s if parsed.pause_s is not None else (30.0 if streaming else 0.0)
|
||||
|
||||
assert resolve([]) == 0.0, "不推流时不该停顿"
|
||||
assert resolve(["--livestream", "2"]) == 30.0, "推流时默认留 30 s 连接时间"
|
||||
assert resolve(["--livestream", "2", "--pause-s", "0"]) == 0.0, "显式 0 可以关掉停顿"
|
||||
assert resolve(["--livestream", "1"]) == 30.0, "公网推流同样要留连接时间"
|
||||
return "不推流默认 0 s;推流默认 30 s;显式值优先"
|
||||
|
||||
|
||||
def test_local_ipv4_addresses_skips_loopback():
|
||||
"""打印给 WebRTC 客户端的地址里不能出现回环地址。"""
|
||||
module = _load_eval_suite()
|
||||
addresses = module._local_ipv4_addresses()
|
||||
assert all(not address.startswith("127.") for address in addresses), addresses
|
||||
assert all(address.count(".") == 3 for address in addresses), addresses
|
||||
return f"拿到 {len(addresses)} 个非回环地址:{addresses}"
|
||||
|
||||
|
||||
def test_record_frames_defaults_off():
|
||||
"""抓帧是可选功能:默认不抓,给了目录和间隔才生效。"""
|
||||
module = _load_eval_suite()
|
||||
parser = module._build_parser()
|
||||
|
||||
plain = parser.parse_args([])
|
||||
assert plain.record_frames is None, "默认不应抓帧"
|
||||
assert plain.record_every == 5, "默认每 5 步一张(0.1 s 仿真时间,合成 10 fps 视频)"
|
||||
|
||||
on = parser.parse_args(["--record-frames", "/tmp/frames", "--record-every", "1"])
|
||||
assert on.record_frames == "/tmp/frames"
|
||||
assert on.record_every == 1
|
||||
return "默认关闭;--record-frames 指定目录、--record-every 指定间隔"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
|
||||
failures: list[tuple[str, str]] = []
|
||||
for test in tests:
|
||||
name = test.__name__
|
||||
try:
|
||||
note = test()
|
||||
except (Exception, SystemExit) as exc: # noqa: BLE001
|
||||
# 也要接住 SystemExit:argparse 参数写错会直接 SystemExit(2),
|
||||
# 不接的话整个测试文件当场退出,后面的测试一条都不跑、也没有汇总行。
|
||||
failures.append((name, f"{type(exc).__name__}: {exc}"))
|
||||
print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
print(f"ok {name}" + (f" — {note}" if note else ""))
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)}/{len(tests)} 个测试失败")
|
||||
return 1
|
||||
print(f"全部 {len(tests)} 个测试通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user