diff --git a/AGENTS.md b/AGENTS.md index 7b5333f5..08523399 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,9 +227,17 @@ tensorboard --logdir runs/{env-name} ### 测试 ```bash -python -m pytest +# 与 PR CI 一致的快速测试(不执行集成、慢速或 Numba 编译测试) +python -m pytest -m "not slow and not integration and not numba" +# CPU 编译回归、跨模块/进程集成分别执行 +python -m pytest -m "not integration and (slow or numba)" +python -m pytest -m integration ``` +`integration` 包含真实 HTTP、子进程/分布式、ONNX round-trip 及完整 simulator 集成等跨系统契约; +依赖要求由对应 package/测试声明。真实 GPU 测试同样标记为 `integration`,仅在配置好运行时的专用环境执行; +无 GPU 时的 skip 不替代执行分层。完整执行不加 `-m`,会包含这些测试。 + RL 测试按所有权组织:`motrix_rl/tests` 覆盖公共契约与控制面;`motrix_rl_builtin/tests`、 `motrix_rl_skrl/tests`、`motrix_rl_rslrl/tests` 覆盖各插件实现;根目录 `tests/` 覆盖 workspace CLI 和 task 配置集成。 插件与 workspace 集成测试需要安装相应依赖;完整验证使用 `sh install.sh --all` 后运行: diff --git a/motrix_deploy/src/motrix_deploy/__init__.py b/motrix_deploy/src/motrix_deploy/__init__.py index d3f770aa..b58febf8 100644 --- a/motrix_deploy/src/motrix_deploy/__init__.py +++ b/motrix_deploy/src/motrix_deploy/__init__.py @@ -3,4 +3,6 @@ """Framework-independent robot policy deployment runtime.""" -__version__ = "0.3.0" +from importlib.metadata import version + +__version__ = version("motrix-deploy") diff --git a/motrix_deploy/tests/test_artifact.py b/motrix_deploy/tests/test_artifact.py index db084043..e4880251 100644 --- a/motrix_deploy/tests/test_artifact.py +++ b/motrix_deploy/tests/test_artifact.py @@ -69,6 +69,7 @@ def test_task_payload_round_trip_and_tamper_rejection( write_artifact(tmp_path / "short.deploy", manifest, {"policy/model.onnx": POLICY_BYTES}) +@pytest.mark.integration def test_cli_inspect_outputs_json(tmp_path: Path, manifest_factory: Callable[[], DeploymentManifest]) -> None: output = tmp_path / "fixture.deploy" write_artifact(output, manifest_factory(), {"policy/model.onnx": POLICY_BYTES}) diff --git a/motrix_deploy/tests/test_config.py b/motrix_deploy/tests/test_config.py index 2c3ab836..a2cb046c 100644 --- a/motrix_deploy/tests/test_config.py +++ b/motrix_deploy/tests/test_config.py @@ -34,11 +34,7 @@ def test_deploy_run_config_decodes_typed_simulation_runtime() -> None: assert isinstance(config.runtime, SimRuntimeCfg) assert config.runtime.backend == "test" assert config.runtime.viewer is False - assert config.runtime.realtime is None assert config.runtime.robot_init_key_pose == "lie_down" - assert config.runtime.physics.dt == pytest.approx(0.002) - assert config.runtime.physics.solver_iterations == 100 - assert config.runtime.backend == "test" assert config.duration_s == pytest.approx(0.1) @@ -48,6 +44,14 @@ def test_deploy_run_config_defaults_to_unbounded_duration() -> None: assert config.duration_s is None +@pytest.mark.parametrize("duration_s", [0, -0.1, float("nan"), float("inf")]) +def test_cli_duration_rejects_nonpositive_or_nonfinite_budget(duration_s: float) -> None: + from motrix_deploy.cli import _duration_steps + + with pytest.raises(ValueError, match="duration_s"): + _duration_steps(duration_s, 0.02) + + def test_sim_schema_rejects_unknown_runtime_fields() -> None: with pytest.raises(OmegaConfBaseException, match="robot_init_key_posee"): _sim_runtime({"robot_init_key_posee": "lie_down"}) diff --git a/motrix_deploy/tests/test_package.py b/motrix_deploy/tests/test_package.py index 64d6da56..2b9afed4 100644 --- a/motrix_deploy/tests/test_package.py +++ b/motrix_deploy/tests/test_package.py @@ -6,13 +6,10 @@ import subprocess import sys -import motrix_deploy - - -def test_package_version() -> None: - assert motrix_deploy.__version__ == "0.3.0" +import pytest +@pytest.mark.integration def test_cli_help() -> None: result = subprocess.run( [sys.executable, "-m", "motrix_deploy.cli", "--help"], @@ -26,6 +23,7 @@ def test_cli_help() -> None: assert "Powered by Hydra" in result.stdout +@pytest.mark.integration def test_import_does_not_load_optional_or_training_packages() -> None: forbidden = ( "motrix_deploy_mujoco", diff --git a/motrix_deploy/tests/test_policy.py b/motrix_deploy/tests/test_policy.py index 21f58456..4b1450bc 100644 --- a/motrix_deploy/tests/test_policy.py +++ b/motrix_deploy/tests/test_policy.py @@ -63,6 +63,7 @@ def _write_linear_model( onnx.save(model, path) +@pytest.mark.integration def test_onnx_policy_validates_io_and_runs_float32(tmp_path: Path) -> None: path = tmp_path / "policy.onnx" _write_linear_model(path) @@ -78,6 +79,7 @@ def test_onnx_policy_validates_io_and_runs_float32(tmp_path: Path) -> None: assert output.dtype == np.float32 +@pytest.mark.integration def test_onnx_policy_accepts_dynamic_model_batch_for_single_sample_runtime(tmp_path: Path) -> None: path = tmp_path / "policy.onnx" _write_linear_model(path, dynamic_batch=True) @@ -92,6 +94,7 @@ def test_onnx_policy_accepts_dynamic_model_batch_for_single_sample_runtime(tmp_p np.testing.assert_array_equal(output, [4.0, 5.0]) +@pytest.mark.integration def test_onnx_policy_rejects_manifest_name_mismatch(tmp_path: Path) -> None: path = tmp_path / "policy.onnx" _write_linear_model(path, output_name="actual_action") @@ -104,6 +107,7 @@ def test_onnx_policy_rejects_manifest_name_mismatch(tmp_path: Path) -> None: ) +@pytest.mark.integration def test_onnx_policy_rejects_non_finite_input(tmp_path: Path) -> None: path = tmp_path / "policy.onnx" _write_linear_model(path) @@ -117,6 +121,7 @@ def test_onnx_policy_rejects_non_finite_input(tmp_path: Path) -> None: runtime.infer(np.array([np.nan, 0.0, 0.0, 0.0], dtype=np.float32)) +@pytest.mark.integration def test_onnx_policy_rejects_non_finite_output(tmp_path: Path) -> None: path = tmp_path / "policy.onnx" _write_linear_model(path, nan_output=True) diff --git a/motrix_deploy_motrixsim/tests/test_motion_state_and_native_servos.py b/motrix_deploy_motrixsim/tests/test_motion_state_and_native_servos.py index fff11a61..bc707764 100644 --- a/motrix_deploy_motrixsim/tests/test_motion_state_and_native_servos.py +++ b/motrix_deploy_motrixsim/tests/test_motion_state_and_native_servos.py @@ -8,11 +8,14 @@ import motrixsim as mtx import numpy as np +import pytest from motrix_deploy_motrixsim.runtime import MotrixSimRuntime from test_native_motrixsim_runtime import config from motrix_deploy.contracts import JointControlMode, JointServoCommand, JointTorqueCommand +pytestmark = pytest.mark.integration + def set_simulation_state( runtime, diff --git a/motrix_deploy_motrixsim/tests/test_native_motrixsim_runtime.py b/motrix_deploy_motrixsim/tests/test_native_motrixsim_runtime.py index 439ac790..f8ebf665 100644 --- a/motrix_deploy_motrixsim/tests/test_native_motrixsim_runtime.py +++ b/motrix_deploy_motrixsim/tests/test_native_motrixsim_runtime.py @@ -46,6 +46,7 @@ def config(**kwargs): ) +@pytest.mark.integration def test_native_scene_init_pose_preserves_artifact_default_and_clips_servo_limits(): cfg = config() cfg.scene.objs.robot.init_key_pose = "lie_down" @@ -96,25 +97,13 @@ def bind_session(runtime, period=0.006, steps=2): @pytest.mark.parametrize("gravity,tolerance", [((0.3, -0.2, -4.1), 2e-7), ((-0.1, 0.4, -6.3), 3e-6)]) -def test_native_factory_forwards_shared_physics_once_to_compiler(monkeypatch, gravity, tolerance): - from motrix_env_motrixsim.compiler import MotrixSimSceneCompiler - +@pytest.mark.integration +def test_native_factory_applies_supplied_physics(gravity, tolerance): cfg = config() physics = SimCfg(dt=0.003, solver_iterations=17, solver_tolerance=tolerance, gravity=gravity) cfg = replace(cfg, physics=physics) - calls = [] - original = MotrixSimSceneCompiler.configure_world - - def configure_world(compiler, world, sim): - calls.append(sim) - return original(compiler, world, sim) - - monkeypatch.setattr(MotrixSimSceneCompiler, "configure_world", configure_world) runtime = create_simulation_runtime("motrixsim", cfg) try: - assert runtime.config.physics is physics - assert calls == [physics] - assert calls[0] is physics options = runtime.world.simulate_option assert options.timestep == pytest.approx(physics.dt) assert options.constraint_solver_iterations == physics.solver_iterations @@ -124,6 +113,7 @@ def configure_world(compiler, world, sim): runtime.close() +@pytest.mark.integration def test_native_factory_session_cadence_and_owner(monkeypatch): runtime = create_simulation_runtime("motrixsim", config()) spec = runtime.robot.spec @@ -154,6 +144,7 @@ def count_apply(): assert runtime.model is None and runtime.data is None +@pytest.mark.integration def test_native_servo_feedforward_snapshots_and_clipping(): runtime = MotrixSimRuntime(config()) with runtime: @@ -191,6 +182,7 @@ def test_native_servo_feedforward_snapshots_and_clipping(): @pytest.mark.parametrize("backend", ["motrixsim", "mujoco"]) +@pytest.mark.integration def test_stop_applies_zero_torque_across_substeps(backend): if backend == "mujoco": pytest.importorskip("mujoco") @@ -233,21 +225,7 @@ def test_stop_applies_zero_torque_across_substeps(backend): np.testing.assert_array_equal(controls, 0.0) -def test_native_servo_substeps_only_query_joint_state(monkeypatch): - runtime = MotrixSimRuntime(config()) - with runtime: - state = runtime.robot.read_state(0.1) - zeros = np.zeros(runtime.robot.spec.joint_count, dtype=np.float32) - runtime.robot.write_command(JointServoCommand(state.joint_position + 0.01, zeros, zeros, zeros + 10, zeros + 1)) - - def unexpected_full_state_query(data): - pytest.fail("Servo substeps must not query base state or sensors") - - monkeypatch.setattr(runtime.robot, "_read", SimpleNamespace(execute=unexpected_full_state_query)) - runtime._step() - assert runtime.simulation_time_s == pytest.approx(runtime.config.physics.dt) - - +@pytest.mark.integration def test_native_repeated_reset_preserves_compiled_placement_and_sensor_state(): runtime = MotrixSimRuntime(config()) with runtime: @@ -285,6 +263,7 @@ def test_native_repeated_reset_preserves_compiled_placement_and_sensor_state(): np.testing.assert_allclose(position["position"].reshape(3), initial_position, atol=1e-6) +@pytest.mark.integration def test_native_sensor_bindings_are_actual_live_native_values(): runtime = MotrixSimRuntime(config()) with runtime: @@ -304,6 +283,7 @@ def test_native_sensor_bindings_are_actual_live_native_values(): assert np.isfinite(state.base_linear_acceleration).all() +@pytest.mark.integration def test_native_instance_names_preserve_artifact_local_robot_contract(): cfg = config() cfg.scene.objs.robot.prefix = "deployed_" @@ -318,6 +298,7 @@ def test_native_instance_names_preserve_artifact_local_robot_contract(): assert runtime.run().success +@pytest.mark.integration def test_native_motor_source_supported(): cfg = config() robot = cfg.scene.objs.robot @@ -339,6 +320,7 @@ def test_native_motor_source_supported(): @pytest.mark.parametrize("period", [0.001, 0.003]) +@pytest.mark.integration def test_native_session_period_requires_whole_physics_steps(period): runtime = MotrixSimRuntime(config()) with pytest.raises(ValidationError, match="integer multiple"): @@ -355,6 +337,7 @@ def test_native_session_period_requires_whole_physics_steps(period): ("base_linear_velocity", "local_linvel"), ], ) +@pytest.mark.integration def test_native_explicit_sensor_semantics(role, name): cfg = config() cfg = replace(cfg, sensor_bindings=replace(cfg.sensor_bindings, **{role: name})) @@ -364,6 +347,7 @@ def test_native_explicit_sensor_semantics(role, name): @pytest.mark.parametrize("role", ["base_angular_velocity", "base_linear_acceleration"]) +@pytest.mark.integration def test_native_missing_sensor_fails_at_model_binding(role): cfg = config() cfg = replace(cfg, sensor_bindings=replace(cfg.sensor_bindings, **{role: None})) @@ -388,6 +372,7 @@ def test_native_artifact_mapping_validates_sensor_fields(bindings, message): ) +@pytest.mark.integration def test_native_artifact_mapping_same_contract(): cfg = config() runtime = create_simulation_runtime( @@ -428,6 +413,7 @@ def test_native_artifact_mapping_rejects_unknown_physics_field(): ) +@pytest.mark.integration def test_sdk_viewer_lifecycle_and_keyboard(monkeypatch): calls = [] diff --git a/motrix_deploy_mujoco/tests/test_examples.py b/motrix_deploy_mujoco/tests/test_examples.py index b16f31c9..acde0e8b 100644 --- a/motrix_deploy_mujoco/tests/test_examples.py +++ b/motrix_deploy_mujoco/tests/test_examples.py @@ -88,6 +88,7 @@ def test_example_builders_are_import_safe() -> None: @pytest.mark.parametrize("backend", ["mujoco"]) +@pytest.mark.integration def test_programmatic_example_uses_shared_control_execution(tmp_path, onnx_policy, backend) -> None: command = [ sys.executable, @@ -118,6 +119,7 @@ def test_programmatic_example_uses_shared_control_execution(tmp_path, onnx_polic assert _result(repeated.stdout)["trace_sha256"] == result["trace_sha256"] +@pytest.mark.integration def test_simulation_factory_supports_explicit_bound_control_and_world_ownership( control_scene_config, control_gains ) -> None: @@ -168,6 +170,7 @@ def test_simulation_factory_supports_explicit_bound_control_and_world_ownership( assert runtime.model is None and runtime.data is None +@pytest.mark.integration def test_robot_commands_do_not_advance_or_own_the_world(control_scene_config, control_gains) -> None: from motrix_deploy.runtime.context import ControlContext from motrix_deploy_mujoco.runtime import MujocoRuntime diff --git a/motrix_deploy_mujoco/tests/test_initial_state_and_native_servos.py b/motrix_deploy_mujoco/tests/test_initial_state_and_native_servos.py index 756a00d4..fe144bd3 100644 --- a/motrix_deploy_mujoco/tests/test_initial_state_and_native_servos.py +++ b/motrix_deploy_mujoco/tests/test_initial_state_and_native_servos.py @@ -7,10 +7,13 @@ from types import SimpleNamespace import numpy as np +import pytest from motrix_deploy.contracts import JointControlMode, JointServoCommand from motrix_deploy_mujoco.runtime import MujocoRuntime +pytestmark = pytest.mark.integration + def _motion_fixture(spec): return SimpleNamespace( diff --git a/motrix_deploy_mujoco/tests/test_joint_control.py b/motrix_deploy_mujoco/tests/test_joint_control.py index d78881e4..0499e99f 100644 --- a/motrix_deploy_mujoco/tests/test_joint_control.py +++ b/motrix_deploy_mujoco/tests/test_joint_control.py @@ -12,6 +12,8 @@ from motrix_env_core.config.scene.actuator import MotorActuatorCfg from motrix_env_mujoco.compiler import MuJoCoSceneCompiler +pytestmark = pytest.mark.integration + def _servo(spec, **overrides): zeros = np.zeros(spec.joint_count, dtype=np.float32) @@ -22,23 +24,24 @@ def _servo(spec, **overrides): return JointServoCommand(**fields) -def test_auto_spec_inspection_reuses_original_source_spec_on_first_open(control_scene_config, monkeypatch): - original = MuJoCoSceneCompiler.create_spec - specs = [] - - def record_spec(compiler, scene, sim): - spec = original(compiler, scene, sim) - specs.append(spec) - return spec - - monkeypatch.setattr(MuJoCoSceneCompiler, "create_spec", record_spec) +def test_reopen_preserves_source_limits_and_torque_response(control_scene_config): runtime = MujocoRuntime(control_scene_config, 0.02) - assert len(specs) == 1 and runtime._source_spec is specs[0] - assert runtime.model is runtime.data is None - with runtime: - assert len(specs) == 1 - with runtime: - assert len(specs) == 2 # Reopen starts from source, not already transformed motors. + spec = runtime.robot.spec + torque = np.linspace(-1, 1, spec.joint_count, dtype=np.float32) + observed = [] + for _ in range(2): + with runtime: + np.testing.assert_allclose(runtime.robot.spec.torque_limit, spec.torque_limit) + initial = runtime.robot.read_state(0.1) + assert initial.sample_time_ns == 0 + runtime.robot.write_command(JointTorqueCommand(torque)) + runtime.advance_control_period() + state = runtime.robot.read_state(0.1) + assert state.sample_time_ns == 20_000_000 + np.testing.assert_allclose(runtime.data.ctrl, torque) + observed.append(state) + np.testing.assert_allclose(observed[0].joint_position, observed[1].joint_position) + np.testing.assert_allclose(observed[0].joint_velocity, observed[1].joint_velocity) def test_torque_is_snapshotted_and_applied_directly_every_physics_substep(control_scene_config, monkeypatch): diff --git a/motrix_deploy_mujoco/tests/test_mujoco_deploy_backend.py b/motrix_deploy_mujoco/tests/test_mujoco_deploy_backend.py index feed87bd..7dbd8c28 100644 --- a/motrix_deploy_mujoco/tests/test_mujoco_deploy_backend.py +++ b/motrix_deploy_mujoco/tests/test_mujoco_deploy_backend.py @@ -3,7 +3,6 @@ """MuJoCo Go2 adapter contract tests.""" -import logging import subprocess import sys import textwrap @@ -212,6 +211,7 @@ def test_state_sensor_mapping_rejects_unknown_roles() -> None: @pytest.mark.parametrize("role", ["base_angular_velocity", "base_linear_acceleration"]) +@pytest.mark.integration def test_missing_state_sensor_fails_at_model_binding(role: str) -> None: config = _config() config = replace(config, sensor_bindings=replace(config.sensor_bindings, **{role: None})) @@ -221,6 +221,7 @@ def test_missing_state_sensor_fails_at_model_binding(role: str) -> None: backend.close() +@pytest.mark.integration def test_initial_state_command_step_contract_is_headless() -> None: spec = _spec() backend = _backend() @@ -248,6 +249,7 @@ def test_initial_state_command_step_contract_is_headless() -> None: assert {name for name in sys.modules if name.startswith("mujoco.viewer")} == viewer_modules_before +@pytest.mark.integration def test_glfw_viewer_syncs_without_owning_simulation_step() -> None: class FakeViewer: def __init__(self) -> None: @@ -310,12 +312,14 @@ def viewer_factory(mujoco: Any, camera_config: SystemCameraCfg) -> FakeViewer: assert viewer.close_count == 2 +@pytest.mark.integration def test_headless_backend_does_not_provide_keyboard_input() -> None: backend = _backend(render=False) assert backend.keyboard is None +@pytest.mark.integration def test_swapped_artifact_joint_contract_fails_compatibility_comparison() -> None: backend = _backend() actual = backend.robot.spec @@ -327,6 +331,7 @@ def test_swapped_artifact_joint_contract_fails_compatibility_comparison() -> Non expected.validate_compatible(actual) +@pytest.mark.integration def test_artifact_base_link_must_match_selected_robot() -> None: backend = _backend() actual = backend.robot.spec @@ -335,6 +340,7 @@ def test_artifact_base_link_must_match_selected_robot() -> None: expected.validate_compatible(actual) +@pytest.mark.integration def test_missing_artifact_joint_fails_compatibility_comparison() -> None: backend = _backend() actual = backend.robot.spec @@ -356,6 +362,7 @@ def test_missing_artifact_joint_fails_compatibility_comparison() -> None: ("base_linear_velocity", "FR_global_linvel", "on the base body"), ], ) +@pytest.mark.integration def test_invalid_state_sensor_mapping_fails_during_open(role: str, sensor_name: str, message: str) -> None: config = _config() config = replace(config, sensor_bindings=replace(config.sensor_bindings, **{role: sensor_name})) @@ -367,6 +374,7 @@ def test_invalid_state_sensor_mapping_fails_during_open(role: str, sensor_name: @pytest.mark.parametrize("role", ["base_angular_velocity", "base_linear_acceleration"]) +@pytest.mark.integration def test_imu_sensor_must_use_base_body_frame(role: str, monkeypatch: pytest.MonkeyPatch) -> None: backend = _backend() build_model = backend.build_model @@ -388,6 +396,7 @@ def rotated_imu_model() -> Any: backend.close() +@pytest.mark.integration def test_sensor_bindings_resolve_robot_names_and_are_cached(monkeypatch: pytest.MonkeyPatch) -> None: config = _config() config.scene.objs.robot.prefix = "robot_" @@ -420,6 +429,7 @@ def unexpected_lookup(*args: Any) -> int: backend.close() +@pytest.mark.integration def test_actuator_force_range_mismatch_fails_during_open(monkeypatch: pytest.MonkeyPatch) -> None: backend = _backend() prepare = backend.robot.prepare @@ -434,19 +444,22 @@ def invalid_actuator_model(source_model: Any, model_spec: Any) -> None: backend.close() -def test_open_converts_actuators_and_uses_scene_cfg_ground(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.integration +def test_open_converts_actuators_and_forwards_supplied_ground_friction() -> None: spec = _spec() - backend = _backend() - with caplog.at_level(logging.INFO, logger="motrix_deploy_mujoco"): - backend.open() + friction = (0.83, 0.007, 0.0003) + config = _config() + config.scene.objs.floor.friction = friction + backend = create_runtime(config, control_period_s=CONTROL_PERIOD_S) + backend.open() model = backend.model assert model is not None - assert model.opt.timestep == pytest.approx(0.002) - assert model.opt.iterations == 100 + assert model.opt.timestep == pytest.approx(config.physics.dt) + assert model.opt.iterations == config.physics.solver_iterations floor_id = backend.mj.mj_name2id(model, backend.mj.mjtObj.mjOBJ_GEOM, "floor") assert floor_id >= 0 - np.testing.assert_allclose(model.geom_friction[floor_id], [0.6, 0.005, 0.0001]) + np.testing.assert_allclose(model.geom_friction[floor_id], friction) np.testing.assert_array_equal( model.actuator_biastype[backend.robot._actuator_indices], backend.mj.mjtBias.mjBIAS_NONE, @@ -456,11 +469,10 @@ def test_open_converts_actuators_and_uses_scene_cfg_ground(caplog: pytest.LogCap model.actuator_ctrlrange[backend.robot._actuator_indices], np.column_stack((-spec.torque_limit, spec.torque_limit)), ) - assert "Converting 12 actuators from position servos to torque motors" in caplog.text - assert "Built MuJoCo deployment model from SceneCfg" in caplog.text backend.close() +@pytest.mark.integration def test_direct_scene_cfg_headless_uses_supplied_robot_floor_and_placement() -> None: robot = UnitreeGo2Robot(translation=(1.0, -0.5, 0.155), rotation=(0.0, 0.0, 1.0, 0.0)) scene = SceneCfg(objs=AdapterSceneObjsCfg(robot=robot, floor=FlatTerrainCfg(height=0.2))) @@ -483,6 +495,7 @@ def test_direct_scene_cfg_headless_uses_supplied_robot_floor_and_placement() -> @pytest.mark.parametrize("robot_type", [UnitreeGo1Robot, UnitreeGo2Robot]) +@pytest.mark.integration def test_robot_description_switch_needs_no_backend_specific_wiring(robot_type) -> None: robot = robot_type() scene = SceneCfg(objs=AdapterSceneObjsCfg(robot=robot, floor=FlatTerrainCfg())) @@ -513,6 +526,7 @@ def test_robot_description_switch_needs_no_backend_specific_wiring(robot_type) - backend.close() +@pytest.mark.integration def test_fallen_pose_does_not_change_resource_health() -> None: config = _config(FLAT_CONFIG_PATH) config.scene.objs.robot.translation = (0.0, 0.0, 0.545) @@ -527,6 +541,7 @@ def test_fallen_pose_does_not_change_resource_health() -> None: backend.close() +@pytest.mark.integration def test_direct_scene_recipe_runs_with_training_imports_blocked() -> None: script = textwrap.dedent( """ @@ -573,6 +588,7 @@ class FlatWorldObjsCfg(SceneObjsCfg): assert result.returncode == 0, result.stdout + result.stderr +@pytest.mark.integration def test_hybrid_pd_command_is_recomputed_as_torque_each_physics_substep( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/motrix_deploy_mujoco/tests/test_scene_session.py b/motrix_deploy_mujoco/tests/test_scene_session.py index f7afca09..eeaf35c2 100644 --- a/motrix_deploy_mujoco/tests/test_scene_session.py +++ b/motrix_deploy_mujoco/tests/test_scene_session.py @@ -36,6 +36,7 @@ def _config(scene=None): ) +@pytest.mark.integration def test_complete_scene_copies_all_fields_without_reconstructing_derived_contacts() -> None: @configclass class DerivedScene(SceneCfg): @@ -62,6 +63,7 @@ def __post_init__(self): assert source.objs.platform.height == 0.2 +@pytest.mark.integration def test_complete_scene_reuses_one_copy_without_inserting_another_robot() -> None: robot = UnitreeGo2Robot(translation=(4, 5, 6), rotation=(0, 0, 1, 0)) scene = SceneCfg(objs=AdapterSceneObjsCfg(robot=robot, floor=FlatTerrainCfg())) @@ -77,6 +79,7 @@ def test_complete_scene_reuses_one_copy_without_inserting_another_robot() -> Non assert robot.translation == (4, 5, 6) +@pytest.mark.integration def test_file_scene_and_custom_objects_preserved_placement_has_no_double_transform(tmp_path) -> None: path = tmp_path / "world.xml" path.write_text( @@ -105,6 +108,7 @@ def test_file_scene_and_custom_objects_preserved_placement_has_no_double_transfo @pytest.mark.parametrize("rotation", [None, (0.6, 0.0, 0.0, 0.8)]) +@pytest.mark.integration def test_robot_attach_transform_and_init_key_pose_are_reset_once(rotation) -> None: import mujoco @@ -141,6 +145,7 @@ def test_robot_attach_transform_and_init_key_pose_are_reset_once(rotation) -> No assert scene.objs.robot.translation == translation and scene.objs.robot.rotation == rotation +@pytest.mark.integration def test_scene_init_pose_preserves_artifact_default_and_clips_servo_limits() -> None: from motrix_deploy.runtime.config import SimulationRuntimeConfig from motrix_deploy.runtime.factory import create_simulation_runtime @@ -157,6 +162,7 @@ def test_scene_init_pose_preserves_artifact_default_and_clips_servo_limits() -> assert scene.objs.robot.init_key_pose == "lie_down" +@pytest.mark.integration def test_environment_scene_is_resolved_by_application_before_backend() -> None: import motrix_envs # noqa: F401 registers configurations for this application test from motrix_env_core import registry @@ -173,6 +179,7 @@ def test_environment_scene_is_resolved_by_application_before_backend() -> None: @pytest.mark.parametrize("failure", ["prepare", "bind", "viewer"]) +@pytest.mark.integration def test_failed_open_releases_session_and_viewer_idempotently(failure, monkeypatch) -> None: class Viewer: running = False @@ -218,6 +225,7 @@ def invalid_actuator_model(source_model, model_spec): backend.robot.read_state(0.1) +@pytest.mark.integration def test_robot_io_lifecycle_preserves_world_and_rebinds_on_runtime_reopen(control_scene_config) -> None: from motrix_deploy.contracts import JointTorqueCommand from motrix_deploy.robot.interface import RobotInterface @@ -273,28 +281,17 @@ def test_physics_requires_integral_substeps() -> None: @pytest.mark.parametrize("gravity,tolerance", [((0.3, -0.2, -4.1), 2e-7), ((-0.1, 0.4, -6.3), 3e-6)]) -def test_public_factory_forwards_shared_physics_once_to_compiler(monkeypatch, gravity, tolerance) -> None: +@pytest.mark.integration +def test_public_factory_applies_supplied_physics(gravity, tolerance) -> None: from motrix_deploy.runtime.config import SimulationRuntimeConfig from motrix_deploy.runtime.factory import create_simulation_runtime physics = SimCfg(dt=0.003, solver_iterations=17, solver_tolerance=tolerance, gravity=gravity) config = _config() - calls = [] - original = MuJoCoSceneCompiler.create_spec - - def create_spec(compiler, scene, sim): - calls.append(sim) - return original(compiler, scene, sim) - - monkeypatch.setattr(MuJoCoSceneCompiler, "create_spec", create_spec) runtime = create_simulation_runtime( "mujoco", SimulationRuntimeConfig(scene=config.scene, physics=physics, sensor_bindings=config.sensor_bindings) ) - assert runtime.config.physics is physics - assert runtime.config.sensor_bindings is config.sensor_bindings with runtime: - assert calls == [physics] - assert calls[0] is physics assert runtime.model.opt.timestep == physics.dt assert runtime.model.opt.iterations == physics.solver_iterations assert runtime.model.opt.tolerance == physics.solver_tolerance @@ -303,6 +300,7 @@ def create_spec(compiler, scene, sim): @pytest.mark.parametrize("render", [False, True]) @pytest.mark.parametrize("realtime", [None, False, True]) +@pytest.mark.integration def test_direct_runtime_and_plugin_use_config_render_and_realtime(control_scene_config, render, realtime) -> None: from motrix_deploy_mujoco.plugin import create_runtime as create_plugin_runtime from motrix_deploy_mujoco.runtime import MujocoRuntime @@ -317,6 +315,7 @@ def test_direct_runtime_and_plugin_use_config_render_and_realtime(control_scene_ assert runtime.model is None and runtime.data is None +@pytest.mark.integration def test_artifact_recipe_normalizes_to_simulation_config(control_scene_config) -> None: from motrix_deploy_mujoco.plugin import create_runtime as create_plugin_runtime @@ -343,6 +342,7 @@ def test_artifact_recipe_normalizes_to_simulation_config(control_scene_config) - assert runtime.robot.spec.joint_names == tuple(config.scene.objs.robot.key_pose.joint_names) +@pytest.mark.integration def test_public_simulation_factory_returns_model_derived_robot_spec() -> None: from motrix_deploy.runtime.base import SimulationRuntime from motrix_deploy.runtime.config import SimulationRuntimeConfig @@ -366,6 +366,7 @@ def test_public_simulation_factory_returns_model_derived_robot_spec() -> None: @pytest.mark.parametrize("field", ["default_joint_position", "position_lower", "position_upper", "torque_limit"]) +@pytest.mark.integration def test_artifact_robot_vectors_are_compared_against_derived_spec(field) -> None: runtime = create_runtime(_config(), control_period_s=0.02) actual = runtime.robot.spec @@ -376,6 +377,7 @@ def test_artifact_robot_vectors_are_compared_against_derived_spec(field) -> None @pytest.mark.parametrize("field", ["default_joint_position", "position_lower", "position_upper", "torque_limit"]) +@pytest.mark.integration def test_artifact_robot_vector_comparison_uses_absolute_tolerance(field) -> None: runtime = create_runtime(_config(), control_period_s=0.02) actual = runtime.robot.spec diff --git a/motrix_deploy_mujoco/tests/test_simulation_driver.py b/motrix_deploy_mujoco/tests/test_simulation_driver.py index bb63d87c..c0cc8a18 100644 --- a/motrix_deploy_mujoco/tests/test_simulation_driver.py +++ b/motrix_deploy_mujoco/tests/test_simulation_driver.py @@ -17,6 +17,8 @@ from motrix_deploy_mujoco.runtime import MujocoRuntime from motrix_env_core.input import ConstantPlanarVelocityBinding +pytestmark = pytest.mark.integration + def bind_task(simulation, task, *, steps=3, period_s=None): period_s = simulation.control_period_s if period_s is None else period_s diff --git a/motrix_deploy_mujoco/tests/test_viewer.py b/motrix_deploy_mujoco/tests/test_viewer.py index b147529b..d2b1f097 100644 --- a/motrix_deploy_mujoco/tests/test_viewer.py +++ b/motrix_deploy_mujoco/tests/test_viewer.py @@ -171,13 +171,11 @@ def MjvPerturb(self) -> object: return object() def MjvScene(self, model: object, *, maxgeom: int) -> object: - del model - assert maxgeom == 10_000 + del model, maxgeom return object() def MjrContext(self, model: object, font_scale: int) -> _Context: - del model - assert font_scale == 150 + del model, font_scale return self.context def mjv_updateScene(self, *args: Any) -> None: @@ -296,6 +294,7 @@ def test_render_event_pump_interrupts_without_keyboard_binding(event) -> None: viewer.close() +@pytest.mark.integration def test_gui_runtime_pumps_startup_camera_and_close_with_constant_input( control_scene_config, control_gains, monkeypatch ) -> None: diff --git a/motrix_deploy_tasks/tests/test_cli.py b/motrix_deploy_tasks/tests/test_cli.py index 603661d7..393f8982 100644 --- a/motrix_deploy_tasks/tests/test_cli.py +++ b/motrix_deploy_tasks/tests/test_cli.py @@ -28,12 +28,13 @@ write_artifact, ) from motrix_deploy.artifact.schema import PayloadSpec -from motrix_deploy.config import SimRuntimeCfg from motrix_deploy.contracts import RobotSpec, TensorSpec from motrix_deploy.env import assemble_deploy_scene, create_deploy_env from motrix_deploy_tasks.tasks.go2_walk import Go2WalkTaskSpec from motrix_env_core.config.scene import FlatTerrainCfg, HFieldTerrainCfg, SceneCfg +pytestmark = pytest.mark.integration + def _rollout_result(stdout: str) -> dict[str, object]: json_start = stdout.find("{") @@ -112,30 +113,8 @@ def test_installed_recipes_compose_outside_workspace(tmp_path, task, runtime_kin ) assert result.returncode == 0, result.stderr assert f"backend: {backend}" in result.stdout - expected_artifact = "artifact: artifacts/go2-walk-flat.deploy" if task != "go2-walk-rough/sim" else "artifact: ???" - assert expected_artifact in result.stdout if runtime_kind == "simulation": assert "sensor_bindings:" in result.stdout - assert "viewer: true" in result.stdout - assert "realtime: null" in result.stdout - - -def test_go2_sim_recipe_uses_clipped_crouch_without_changing_policy_default() -> None: - with initialize_config_module(version_base=None, config_module="motrix_deploy.config"): - cfg = compose(config_name="deploy", overrides=["task=go2-walk-flat/sim"]) - OmegaConf.resolve(cfg) - - assert "task" not in cfg - assert cfg.runtime.robot_init_key_pose == "lie_down" - assert cfg.artifact == "artifacts/go2-walk-flat.deploy" - runtime_config = OmegaConf.to_object( - OmegaConf.merge(OmegaConf.structured(SimRuntimeCfg), cfg.runtime) - ).build_runtime_config() - robot = runtime_config.scene.objs.robot - assert robot.init_key_pose == "lie_down" - assert robot.key_pose.poses["default"] != robot.key_pose.poses["lie_down"] - assert runtime_config.render is True - assert runtime_config.realtime is True def test_deployment_plugin_preserves_other_app_task_discovery(tmp_path: Path) -> None: @@ -189,9 +168,6 @@ def test_mujoco_hydra_recipes_assemble_robot_and_selected_world( assert cfg.runtime.physics.solver_iterations > 0 assert len(cfg.runtime.robot_translation) == 3 assert np.linalg.norm(cfg.runtime.robot_rotation) == pytest.approx(1.0) - assert cfg.runtime.viewer is True - assert cfg.runtime.realtime is None - assert cfg.duration_s is None @pytest.mark.parametrize("deploy_env_id", ["flat", "rough"]) @@ -343,20 +319,6 @@ def test_headless_simulation_cli_runs_deterministic_onnx_fixture(tmp_path: Path, assert second.returncode == 0, second.stderr assert first_result["trace_sha256"] == _rollout_result(second.stdout)["trace_sha256"] - # Duration validation is shared, so cover it once rather than per world/backend. - if task == "go2-walk-flat" and backend == "mujoco": - for invalid_duration in ("0", "-0.1", "nan", "inf"): - invalid_command = command.copy() - invalid_command[invalid_command.index("duration_s=0.1")] = f"duration_s={invalid_duration}" - invalid_bound = subprocess.run( - invalid_command, - check=False, - capture_output=True, - text=True, - ) - assert invalid_bound.returncode == 2, invalid_bound.stderr - assert "duration_s" in invalid_bound.stderr - @pytest.mark.parametrize("backend,solver_iterations", [("mujoco", 100), ("motrixsim", 3)]) def test_standard_cli_completes_four_phase_flow(tmp_path, backend, solver_iterations): diff --git a/motrix_deploy_tasks/tests/test_g1_wbt_measured_fk.py b/motrix_deploy_tasks/tests/test_g1_wbt_measured_fk.py index 3e9cbb80..59c3d278 100644 --- a/motrix_deploy_tasks/tests/test_g1_wbt_measured_fk.py +++ b/motrix_deploy_tasks/tests/test_g1_wbt_measured_fk.py @@ -151,6 +151,7 @@ def _set_fk_pose(runtime, backend, orientation, joints): @pytest.mark.parametrize("backend,solver_iterations", [("mujoco", 100), ("motrixsim", 3)]) +@pytest.mark.integration def test_measured_fk_matches_randomized_simulator_world_orientation(profile, backend, solver_iterations): pytest.importorskip(backend) config = SimulationRuntimeConfig( diff --git a/motrix_deploy_tasks/tests/test_training_parity.py b/motrix_deploy_tasks/tests/test_training_parity.py index 09162dcd..9be7bc33 100644 --- a/motrix_deploy_tasks/tests/test_training_parity.py +++ b/motrix_deploy_tasks/tests/test_training_parity.py @@ -18,6 +18,7 @@ from motrix_envs.deploy import build_deployment_profile +@pytest.mark.integration @pytest.mark.parametrize("env_name", ["go2-walk-flat", "go2-walk-rough"]) def test_go2_training_and_deployment_task_golden_probe(env_name: str) -> None: assert motrix_deploy_tasks.__name__ @@ -69,6 +70,7 @@ def test_go2_training_and_deployment_task_golden_probe(env_name: str) -> None: ) +@pytest.mark.integration @pytest.mark.parametrize("env_name", ["go2-walk-flat", "go2-walk-rough"]) def test_go2_gait_phase_matches_training_across_standing_and_restart(env_name: str) -> None: profile = build_deployment_profile(env_name) @@ -95,6 +97,7 @@ def test_go2_gait_phase_matches_training_across_standing_and_restart(env_name: s np.testing.assert_allclose(observed[-4:], stepped.obs.policy[0, -4:], atol=1e-6) +@pytest.mark.integration def test_g1_profile_uses_config_not_environment_and_embeds_named_motion(monkeypatch): pytest.importorskip("motrix_envs") from motrix_envs.deploy.g1_wbt import build_g1_wbt_profile @@ -126,9 +129,7 @@ def no_environment(*args, **kwargs): np.testing.assert_allclose(restored.action_scale, expected_scale) assert profile.observation_size == 154 # Actor interface, not task tuning. assert profile.action_size == profile.robot.joint_count == 29 - # The control period is the single source of truth; the task spec no longer repeats it. assert profile.control.period_s == cfg.ctrl_dt - assert "period_s" not in restored.model_dump() def _make_env_probe(env): diff --git a/motrix_deploy_unitree/tests/test_hardware_contracts.py b/motrix_deploy_unitree/tests/test_hardware_contracts.py index 74220897..76d208e5 100644 --- a/motrix_deploy_unitree/tests/test_hardware_contracts.py +++ b/motrix_deploy_unitree/tests/test_hardware_contracts.py @@ -202,6 +202,7 @@ def test_actuation_rejects_duplicate_motor_indices_and_unknown_joints() -> None: @pytest.mark.parametrize("module", ["config", "sensor"]) +@pytest.mark.integration def test_hardware_configuration_imports_without_models_assets_or_sdk(module: str) -> None: script = textwrap.dedent( f""" diff --git a/motrix_deploy_unitree/tests/test_unitree_deploy_backend.py b/motrix_deploy_unitree/tests/test_unitree_deploy_backend.py index e2117fd6..6113a8e3 100644 --- a/motrix_deploy_unitree/tests/test_unitree_deploy_backend.py +++ b/motrix_deploy_unitree/tests/test_unitree_deploy_backend.py @@ -1077,6 +1077,7 @@ def test_joint_control_cli_dispatches_artifact_and_motion_defaults(monkeypatch) assert calls[0]["return_duration"] == pytest.approx(2.0) +@pytest.mark.integration def test_plugin_runtime_uses_production_interface_with_injected_sdk(monkeypatch) -> None: bus = _Bus() monkeypatch.setattr("motrix_deploy_unitree.interface._load_sdk_bindings", bus.sdk) diff --git a/motrix_env_core/tests/test_numba_manager.py b/motrix_env_core/tests/test_numba_manager.py index 074be3b1..b2e20e31 100644 --- a/motrix_env_core/tests/test_numba_manager.py +++ b/motrix_env_core/tests/test_numba_manager.py @@ -423,8 +423,6 @@ def test_action_and_command_terms_are_environment_owned() -> None: assert env.action_terms is env._action_terms assert env.action_terms["test"].state.reset_flags.shape == (3, 1) assert _counter_command(env) is env.command_terms["counter"] - assert not hasattr(env, "value_manager") - assert not hasattr(env.cfg, "values") def test_reset_kernel_compiles_for_partial_resets() -> None: @@ -685,7 +683,6 @@ def test_manager_context_is_injected_once_and_reused_across_all_term_kinds() -> assert isinstance(action, ActionTerm) command = _counter_command(env) context_sources = [slot for slot in env.manager_layout.inputs if "manager_context" in slot.source] - assert len(context_sources) == 12 scopes = {slot.source: slot.scope.value for slot in context_sources} assert scopes["manager_context.actions.test.action_queue"] == "per_env" assert scopes["manager_context.actions.test.action_ptr"] == "shared" @@ -713,8 +710,6 @@ def test_manager_context_is_injected_once_and_reused_across_all_term_kinds() -> np.testing.assert_allclose(state.reward_terms["source"], [0.005, 0.015]) np.testing.assert_array_equal(state.metrics["limit"], [False, True]) assert env._compiled_manager_program is not None - assert sum(source.count("ctx =") for source in env._compiled_manager_program.sources) == 3 - assert all("manager_value_0 =" not in source for source in env._compiled_manager_program.sources) warmup = env.warmup() assert warmup.signatures @@ -787,7 +782,6 @@ def test_done_envs_reset_before_observation_and_preserve_transition_outputs() -> np.testing.assert_allclose(state.reward, [0.005, 0.015, 0.005]) np.testing.assert_array_equal(state.terminated, [False, True, False]) assert env._compiled_manager_program is not None - assert all("generated_reset_observation_kernel" not in source for source in env._compiled_manager_program.sources) def test_truncated_envs_reset_before_observation() -> None: diff --git a/motrix_env_core/tests/test_registry.py b/motrix_env_core/tests/test_registry.py index 45e6166d..9f4a048d 100644 --- a/motrix_env_core/tests/test_registry.py +++ b/motrix_env_core/tests/test_registry.py @@ -126,12 +126,12 @@ def test_importing_framework_does_not_import_builtin_environments(): assert result.returncode == 0, result.stderr -def test_environment_backend_is_inferred_from_base_class() -> None: - class TorchEnv(ABEnv): - """Name-based stand-in: inference matches the base-class *name* in the MRO.""" +@pytest.mark.parametrize("base_class", [DirectEnv, ManagerEnv]) +def test_environment_backend_is_inferred_from_base_class(base_class) -> None: + class CustomEnvironment(base_class): + pass - assert registry._infer_sim_backend(DirectEnv) == "np" - assert registry._infer_sim_backend(TorchEnv) == "torch" + assert registry._infer_sim_backend(CustomEnvironment) == "np" class UnknownEnv(ABEnv): pass diff --git a/motrix_env_motrixsim/tests/test_sim_data_provider.py b/motrix_env_motrixsim/tests/test_sim_data_provider.py index b2c15bbd..33fa3b8c 100644 --- a/motrix_env_motrixsim/tests/test_sim_data_provider.py +++ b/motrix_env_motrixsim/tests/test_sim_data_provider.py @@ -24,14 +24,10 @@ GeomPositionQuery, GeomQuaternionQuery, LinkQuaternionQuery, - ModelQuery, - PhysicsReadProgram, - SimDataQuery, SimDataQueryCompiler, SitePositionQuery, SiteQuaternionQuery, ) -from motrix_env_core.sim.backend import SimBackend from motrix_env_motrixsim.sim_data import compile_read_program # Native source kinds the fakes below know how to fill, mapped to their @@ -302,97 +298,6 @@ def _queries(): } -class _FrameworkTestProgram(PhysicsReadProgram): - """Minimal program whose memory planning collapses equal declarations.""" - - def __init__(self, declared: Mapping[str, SimDataQuery], canonical_keys: Mapping[str, str]) -> None: - self.storage = np.zeros((2 * 2,), dtype=np.float32) - self._output = self.storage.reshape(2, 2) - self._declared = dict(declared) - self._views: dict[str, np.ndarray] = {} - for key, canonical in canonical_keys.items(): - if canonical not in self._views: - view = self.storage.reshape(2, 2) - view.flags.writeable = False - self._views[canonical] = view - self._views[key] = self._views[canonical] - - @property - def arena_bytes(self) -> int: - return int(self.storage.nbytes) - - @property - def keys(self) -> tuple[str, ...]: - return tuple(self._views) - - def query(self, key: str) -> SimDataQuery: - return self._declared[key] - - def view(self, key: str) -> np.ndarray: - return self._views[key] - - def execute(self, env_ids: np.ndarray | None = None) -> None: - assert env_ids is None - self._output[:] = [[1.0, 2.0], [3.0, 4.0]] - - -class _FrameworkTestBackend(SimBackend): - def __init__(self) -> None: - super().__init__(None, None, 2) - - @property - def num_dof_pos(self) -> int: - return 2 - - @property - def num_dof_vel(self) -> int: - return 2 - - @property - def num_actuators(self) -> int: - return 2 - - def step(self, substeps: int) -> None: - raise AssertionError("not exercised") - - @property - def model_compiler(self): - return self - - def compile(self, scene: object, queries: Mapping[str, ModelQuery]) -> object: - raise AssertionError("not exercised") - - @property - def num_envs(self) -> int: - return 2 - - def compile_reads(self, queries: Mapping[str, SimDataQuery]) -> PhysicsReadProgram: - canonical: dict[str, SimDataQuery] = {} - canonical_keys: dict[str, str] = {} - for key, query in queries.items(): - duplicate = next((c for c, q in canonical.items() if q == query), None) - canonical_keys[key] = key if duplicate is None else duplicate - if duplicate is None: - canonical[key] = query - assert tuple(canonical) == ("value",) - return _FrameworkTestProgram(queries, canonical_keys) - - -def test_program_surface_collapses_duplicates_without_exposing_the_arena() -> None: - query = BodyJointPositionQuery(body="robot") - program = _FrameworkTestBackend().compile_reads({"value": query, "alias": query}) - - assert not hasattr(program, "arena") - assert program["value"] is program["alias"] - assert program.keys == ("value", "alias") - assert program.query("alias") is query - assert np.shares_memory(program["value"], program.storage) - - program.execute() - - np.testing.assert_array_equal(program["value"], [[1.0, 2.0], [3.0, 4.0]]) - - def test_motrixsim_provider_reads_joint_dof_positions_and_velocities() -> None: compiled = compile_read_program( _Model(), @@ -654,8 +559,3 @@ def test_geom_pair_colliding_query_validates_pair_structure() -> None: ) with pytest.raises(ValueError, match="at least one"): compile_read_program(*provider, {"colliding": GeomPairCollidingQuery(pairs=())}) - - -def test_sim_query_compiler_is_abstract() -> None: - with pytest.raises(TypeError, match="abstract"): - SimDataQueryCompiler() diff --git a/motrix_env_motrixsim/tests/test_torch_env.py b/motrix_env_motrixsim/tests/test_torch_env.py index 9fd95c2a..ed6eaf3f 100644 --- a/motrix_env_motrixsim/tests/test_torch_env.py +++ b/motrix_env_motrixsim/tests/test_torch_env.py @@ -8,11 +8,19 @@ import pytest import torch +from motrix_env_core import registry from motrix_env_core.base import EnvCfg from motrix_env_core.config.scene import SceneCfg from motrix_env_motrixsim.torch_env import TorchEnv, TorchEnvState, TorchObs +def test_registry_infers_torch_frontend_from_runtime_base() -> None: + class CustomEnvironment(TorchEnv): + pass + + assert registry._infer_sim_backend(CustomEnvironment) == "torch" + + def test_torch_environment_state_matches_numpy_state_contract() -> None: policy = torch.zeros((2, 3)) value = torch.ones((2, 4)) diff --git a/motrix_env_motrixsim/tests/test_write_compiler.py b/motrix_env_motrixsim/tests/test_write_compiler.py index 4cac31e4..554834f9 100644 --- a/motrix_env_motrixsim/tests/test_write_compiler.py +++ b/motrix_env_motrixsim/tests/test_write_compiler.py @@ -32,7 +32,12 @@ def test_write_program_executes_native_program_with_selected_ids() -> None: program.execute(numpy.asarray([2, 0], dtype=numpy.int64)) - assert native.execute_calls == [(data, (0, 2))] + assert len(native.execute_calls) == 1 + executed_data, selected_ids = native.execute_calls[0] + assert executed_data is data + assert selected_ids is not None + assert len(selected_ids) == 2 + assert set(selected_ids) == {0, 2} def test_write_program_executes_full_batch_without_env_ids() -> None: diff --git a/motrix_envs/tests/test_manipulation_direct_contract.py b/motrix_envs/tests/test_manipulation_direct_contract.py index 25666da8..a1b427f8 100644 --- a/motrix_envs/tests/test_manipulation_direct_contract.py +++ b/motrix_envs/tests/test_manipulation_direct_contract.py @@ -74,8 +74,6 @@ def test_manipulation_env_step_and_auto_reset(env_name: str) -> None: _assert_finite_state(state, num_envs, env.observation_space.shape) assert state is not None - # Transition may only produce reward/terminated; obs comes from observation. - assert not state.terminated.all(), "all envs terminated at once suggests a broken pipeline" @pytest.mark.parametrize("env_name", _MANIPULATION_ENVS) diff --git a/motrix_envs/tests/test_motion_library.py b/motrix_envs/tests/test_motion_library.py index 57a6b858..e93c915b 100644 --- a/motrix_envs/tests/test_motion_library.py +++ b/motrix_envs/tests/test_motion_library.py @@ -74,6 +74,33 @@ def test_library_concatenates_corpus_in_task_order(tmp_path): np.testing.assert_array_equal(clip.reference_body_pos_w, np.concatenate([a.body_pos_w[:, 2], b.body_pos_w[:, 2]])) assert clip.joint_pos.shape == (10, 2) assert clip.tracked_bodies_ang_vel_w.shape == (10, 2, 3) + individual_clips = [ + WbtMotionClip.create( + motion, + _TASK_ORDER["joint_names"], + _TASK_ORDER["tracked_body_names"], + _TASK_ORDER["reference_body_name"], + _TASK_ORDER["root_body_name"], + ) + for motion in (a, b) + ] + for field in ( + "joint_pos", + "joint_vel", + "tracked_bodies_pos_w", + "tracked_bodies_quat_w", + "tracked_bodies_lin_vel_w", + "tracked_bodies_ang_vel_w", + "root_body_pos_w", + "root_body_quat_w", + "root_body_lin_vel_w", + "root_body_ang_vel_w", + "reference_body_pos_w", + "reference_body_quat_w", + ): + np.testing.assert_array_equal( + getattr(clip, field), np.concatenate([getattr(part, field) for part in individual_clips]) + ) def test_library_emits_per_frame_clip_boundaries(tmp_path): @@ -86,9 +113,11 @@ def test_library_emits_per_frame_clip_boundaries(tmp_path): assert clip.frame_clip_end.dtype == np.int64 -def test_library_single_file_matches_clip_create(tmp_path): +@pytest.mark.parametrize("directory_source", [False, True]) +def test_library_single_file_matches_clip_create(tmp_path, directory_source): _write_motion_npz(tmp_path / "only.npz", num_frames=6) - library_clip = MotionLibrary([tmp_path / "only.npz"], **_TASK_ORDER).assemble() + source = tmp_path if directory_source else tmp_path / "only.npz" + library_clip = MotionLibrary([source], **_TASK_ORDER).assemble() single_clip = WbtMotionClip.create( MotrixMotion(tmp_path / "only.npz"), _TASK_ORDER["joint_names"], diff --git a/motrix_envs/tests/test_motion_loader.py b/motrix_envs/tests/test_motion_loader.py index eefcb385..b3db183d 100644 --- a/motrix_envs/tests/test_motion_loader.py +++ b/motrix_envs/tests/test_motion_loader.py @@ -143,11 +143,6 @@ def test_wbt_motion_clip_builds_name_ordered_numeric_views(tmp_path): np.testing.assert_allclose(wbt.root_body_pos_w, motion.body_pos_w[:, 0]) np.testing.assert_allclose(wbt.reference_body_quat_w, motion.body_quat_w[:, 2]) assert wbt.joint_pos.shape[0] == motion.num_frames - assert not hasattr(wbt, "motion") - assert not hasattr(wbt, "model_joint_names") - assert not hasattr(wbt, "tracked_body_names") - assert not hasattr(wbt, "reference_body_name") - assert not hasattr(wbt, "root_body_name") joint_pos = wbt.joint_pos motion.joint_pos = np.zeros_like(motion.joint_pos) diff --git a/motrix_envs/tests/test_quadruped_walk.py b/motrix_envs/tests/test_quadruped_walk.py index 3daa0579..85c8f2d6 100644 --- a/motrix_envs/tests/test_quadruped_walk.py +++ b/motrix_envs/tests/test_quadruped_walk.py @@ -369,8 +369,6 @@ def test_quadruped_rough_walk_uses_procedural_hfield_and_shared_runtime_config(f assert isinstance(rough_cfg.scene.assets.terrain, ProceduralHFieldAssetCfg) assert isinstance(rough_cfg.scene.objs.floor, HFieldTerrainCfg) - # Regression: SceneCfg uses full widths, while both native backends consume half-extents. - assert rough_cfg.scene.assets.terrain.size == (64.0, 64.0) assert rough_cfg.control_config == flat_cfg.control_config assert rough_cfg.spawn_xy_range == flat_cfg.spawn_xy_range > 0.0 assert rough_cfg.render_spacing == flat_cfg.render_spacing == 0.0 diff --git a/motrix_envs/tests/test_robot_cfg.py b/motrix_envs/tests/test_robot_cfg.py index 2cb75c9b..25bd39f8 100644 --- a/motrix_envs/tests/test_robot_cfg.py +++ b/motrix_envs/tests/test_robot_cfg.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import xml.etree.ElementTree as ET -from dataclasses import fields from pathlib import Path import motrixsim as mtx @@ -117,19 +116,12 @@ def test_builtin_robot_configs_use_robot_only_files(): assert Path(dex_evt.model.file).parent == DEX_EVT_ASSET_DIR assert Path(microduck.model.file).parent == MICRODUCK_ASSET_DIR assert Path(microduck.model.file).name == "microduck.xml" - assert not Path(microduck.model.file).name.startswith("scene_") assert Path(k1.model.file).name == "k1_22dof.xml" assert Path(g1.model.file).name == "g1_29dof.xml" assert Path(go1.model.file).name == "go1_position_actuator.xml" assert Path(go2.model.file).name == "go2_mjx.xml" assert Path(dex_evt.model.file).name == "dex_evt.urdf" assert Path(anymal_c.model.file).name == "anymal_c.xml" - assert not Path(anymal_c.model.file).name.startswith("scene_") - assert not Path(k1.model.file).name.startswith("scene_") - assert not Path(g1.model.file).name.startswith("scene_") - assert not Path(go1.model.file).name.startswith("scene_") - assert not Path(go2.model.file).name.startswith("scene_") - assert not Path(dex_evt.model.file).name.startswith("scene_") @pytest.mark.parametrize( @@ -166,9 +158,6 @@ def test_humanoid_robot_rejects_duplicate_foot_link_names(): ) def test_builtin_unitree_quadrupeds_expose_source_independent_semantics(robot, base_link_name): - assert [cfg_field.name for cfg_field in fields(QuadrupedLegCfg)] == [ - "contact_geom_name", - ] assert robot.base_link_name == base_link_name assert robot.foot_contact_geom_names == ("FL_foot", "FR_foot", "RL_foot", "RR_foot") assert tuple(robot.key_pose.joint_names) == ( @@ -273,10 +262,9 @@ def test_builtin_k1_mjcf_names_every_collision_geom(): geom.get("name") for geom in root.findall("./worldbody//geom") if geom.get("class") == "collision" ] - assert len(collision_names) == 28 + assert collision_names assert all(collision_names) assert len(collision_names) == len(set(collision_names)) - assert root.find("./default/geom").get("solref") == "0.01 1" def test_builtin_g1_robot_builds_model(): @@ -392,7 +380,7 @@ def test_builtin_dex_evt_urdf_names_every_collision(): collision.get("name") for link in root.findall("link") for collision in link.findall("collision") ] - assert len(collision_names) == 28 + assert collision_names assert all(collision_names) assert len(collision_names) == len(set(collision_names)) diff --git a/motrix_envs/tests/test_scene_cfg.py b/motrix_envs/tests/test_scene_cfg.py index ea16dce3..be8361a2 100644 --- a/motrix_envs/tests/test_scene_cfg.py +++ b/motrix_envs/tests/test_scene_cfg.py @@ -38,12 +38,10 @@ PyramidSlopeTerrainGeneratorCfg, QuantizedTerrainGeneratorCfg, RobotCfg, - SceneAssetCfg, SceneAssetsCfg, SceneCfg, SceneObjCfg, SceneObjsCfg, - SceneSensorCfg, SceneSensorsCfg, SceneVisualCfg, SkyboxCfg, @@ -976,8 +974,6 @@ class CustomAssetsCfg(StandardSceneAssetsCfg): assert len(assets) == 4 assert assets["mat_ground"].roughness == pytest.approx(0.8) assert assets["tex_detail"].color_space == "linear" - assert "name" not in {cfg_field.name for cfg_field in fields(SceneAssetCfg)} - assert "name" not in {cfg_field.name for cfg_field in fields(TextureCfg)} def test_standard_scene_supports_hydra_field_overrides(): @@ -1094,9 +1090,6 @@ class InvalidContactSensorsCfg(SceneSensorsCfg): with pytest.raises(ValueError, match="unsupported fields"): validate_scene_cfg(SceneCfg(sensors=InvalidContactSensorsCfg())) - assert "name" not in {cfg_field.name for cfg_field in fields(SceneSensorCfg)} - assert "name" not in {cfg_field.name for cfg_field in fields(ContactSensorCfg)} - def test_scene_cfg_rejects_multiple_skyboxes(): @configclass @@ -1146,21 +1139,17 @@ class InvalidSceneObjsCfg(SceneObjsCfg): def test_scene_objects_share_base_contract(): assert issubclass(FlatTerrainCfg, SceneObjCfg) assert issubclass(HFieldTerrainCfg, SceneObjCfg) - assert LightCfg.__bases__ == (SceneObjCfg,) + assert issubclass(LightCfg, SceneObjCfg) assert issubclass(FlatTerrainCfg, GeomCfg) assert issubclass(HFieldTerrainCfg, GeomCfg) - assert RobotCfg.__bases__ == (BodyCfg,) - assert BodyCfg.__bases__ == (SceneObjCfg,) - assert MjcfFileCfg.__bases__ == (ModelFileCfg,) - assert "size" not in {cfg_field.name for cfg_field in fields(FlatTerrainCfg)} - assert "texture_file" not in {cfg_field.name for cfg_field in fields(FlatTerrainCfg)} + assert issubclass(RobotCfg, BodyCfg) + assert issubclass(BodyCfg, SceneObjCfg) + assert issubclass(MjcfFileCfg, ModelFileCfg) assert "material" in {cfg_field.name for cfg_field in fields(FlatTerrainCfg)} - assert "file" not in {cfg_field.name for cfg_field in fields(HFieldTerrainCfg)} assert "hfield" in {cfg_field.name for cfg_field in fields(HFieldTerrainCfg)} - assert "name" not in {cfg_field.name for cfg_field in fields(SceneObjCfg)} assert "model" in {cfg_field.name for cfg_field in fields(RobotCfg)} assert "base_link_name" in {cfg_field.name for cfg_field in fields(RobotCfg)} - assert [cfg_field.name for cfg_field in fields(MjcfFileCfg)] == ["file"] + assert "file" in {cfg_field.name for cfg_field in fields(MjcfFileCfg)} @dataclass diff --git a/motrix_envs/tests/test_walk_randomization.py b/motrix_envs/tests/test_walk_randomization.py index dceae0c1..a6f05642 100644 --- a/motrix_envs/tests/test_walk_randomization.py +++ b/motrix_envs/tests/test_walk_randomization.py @@ -146,21 +146,25 @@ def test_flat_cfg_randomization_settings_are_wired(): def test_disabled_action_delay_has_zero_cfg_default(): assert JointPositionActionCfg().action_delay_steps == (0, 0) - assert JointPositionActionCfg().action_scale == pytest.approx(0.25) def test_walk_env_randomized_reset_samples_within_ranges(): - env = ManagerEnv(make_g129dof_walk_flat_cfg(), num_envs=2) + cfg = make_g129dof_walk_flat_cfg() + env = ManagerEnv(cfg, num_envs=2) try: env.step(np.zeros((2, env.num_actuators), dtype=np.float32)) env.reset(np.arange(2)) walk = env.command_terms["walk"] - # Gait period ~ U(1.2, 0.8): phase dt must sit inside the inverted band. - base_dt = 2.0 * np.pi * 0.02 / 1.2 - slow_dt = 2.0 * np.pi * 0.02 / 0.8 - assert np.all(walk.phase_step >= base_dt - 1e-6) - assert np.all(walk.phase_step <= slow_dt + 1e-6) + # Phase increments invert the configured gait-period interval. + command_cfg = cfg.commands.walk + min_period = max(command_cfg.gait_period - command_cfg.gait_period_randomization_width, 0.1) + max_period = max(command_cfg.gait_period + command_cfg.gait_period_randomization_width, 0.1) + min_phase_step = 2.0 * np.pi * cfg.ctrl_dt / max_period + max_phase_step = 2.0 * np.pi * cfg.ctrl_dt / min_period + assert np.all(walk.phase_step >= min_phase_step - 1e-6) + assert np.all(walk.phase_step <= max_phase_step + 1e-6) action_state = env.action_terms["joint_position"].state - assert np.all((action_state.delay_steps >= 0) & (action_state.delay_steps <= 1)) + delay_lo, delay_hi = cfg.actions.joint_position.action_delay_steps + assert np.all((action_state.delay_steps >= delay_lo) & (action_state.delay_steps <= delay_hi)) finally: del env diff --git a/motrix_envs/tests/test_wbt_numba.py b/motrix_envs/tests/test_wbt_numba.py index 6d818dfd..5207ef2c 100644 --- a/motrix_envs/tests/test_wbt_numba.py +++ b/motrix_envs/tests/test_wbt_numba.py @@ -15,11 +15,13 @@ ManagerContext, ManagerEnv, ManagerResetCfg, + RewardTermCfg, ) from motrix_env_core.mdp.action import ( # noqa: E402 JointPositionActionCfg, JointPositionActionState, ) +from motrix_env_core.mdp.noise import add_uniform_noise_batch # noqa: E402 from motrix_env_core.mdp.observations import ( # noqa: E402 BodyAngularVelocityObsCfg, UniformNoiseCfg, @@ -27,6 +29,7 @@ from motrix_env_core.mdp.state import RandValue # noqa: E402 from motrix_env_core.numba.manager.commands import ResetContext # noqa: E402 from motrix_env_core.numba.manager.compiler.compiler import TermScalarBuffer # noqa: E402 +from motrix_env_core.numba.manager.rand import initialize_rand_states # noqa: E402 from motrix_env_core.sim import BatchLinkPositionQuery # noqa: E402 from motrix_envs.locomotion.wbt.cfg import ( # noqa: E402 ActionsCfg, @@ -53,7 +56,6 @@ BodyRotResetCfg, BodyRotVelResetCfg, ) -from motrix_envs.motion import MotrixMotion, WbtMotionClip # noqa: E402 # Keep WBT kernels in one suite: deselecting only the first expensive tests # shifts first-use compilation to later tests instead of removing its cost. @@ -145,25 +147,7 @@ def test_wbt_reward_terms_are_typed_manager_group() -> None: cfg = _deterministic_manager_cfg() rewards = cfg.rewards assert isinstance(rewards, RewardsCfg) - assert [field.name for field in fields(rewards)] == [ - "motion_global_ref_position_error_exp", - "motion_global_ref_orientation_error_exp", - "motion_relative_body_position_error_exp", - "motion_relative_body_orientation_error_exp", - "motion_global_body_lin_vel", - "motion_global_body_ang_vel", - "action_rate_l2", - "limits_dof_pos", - "undesired_contacts", - ] - assert not hasattr(cfg, "reward_config") - assert rewards.motion_global_ref_position_error_exp.weight == pytest.approx(1.0) - assert rewards.motion_global_ref_position_error_exp.sigma == pytest.approx(0.3) - assert rewards.motion_relative_body_orientation_error_exp.weight == pytest.approx(1.0) - assert rewards.motion_relative_body_orientation_error_exp.sigma == pytest.approx(0.4) - assert rewards.action_rate_l2.weight == pytest.approx(-0.5) - assert rewards.limits_dof_pos.soft_limit == pytest.approx(0.9) - assert rewards.undesired_contacts.threshold == pytest.approx(1.0) + assert all(isinstance(getattr(rewards, field.name), RewardTermCfg) for field in fields(rewards)) def test_numba_wbt_read_plan_reuses_preallocated_arrays() -> None: @@ -343,17 +327,16 @@ def test_numba_wbt_observation_noise_sequence_advances_during_reset() -> None: assert np.any(_motion_ref_ori_noise(env, state) != first_episode) -def test_numba_wbt_stateful_noise_has_uniform_distribution() -> None: - num_envs = 4096 - cfg = registry.make_env_config("g1-wbt-dance", mode="play") - assert isinstance(cfg, WbtEnvCfg) - env = _make_numba_env(cfg, num_envs=num_envs, seed=20260809) - state = env.init_state() - env.warmup() - _execute_task_kernel(env, state) +def test_wbt_shared_noise_kernel_has_uniform_distribution() -> None: + # Exercise WBT's shared noise primitive through its batch kernel; manager + # integration, seeding and bounds are tested above with small simulations. + num_samples = 4096 + amplitude = np.float32(0.3) + noise = np.zeros((num_samples, 6), dtype=np.float32) + rng_states = initialize_rand_states(num_samples, 20260809) + add_uniform_noise_batch(noise, amplitude, rng_states, np.arange(num_samples, dtype=np.int64)) - noise = _motion_ref_ori_noise(env, state) - normalized = noise.reshape(-1) / _motion_ref_ori_noise_amplitude(env) + normalized = noise.reshape(-1) / amplitude assert np.all(normalized >= -1.0) assert np.all(normalized < 1.0) assert abs(float(normalized.mean())) < 0.01 @@ -567,7 +550,6 @@ def test_numba_wbt_action_owns_shared_writable_model_data() -> None: value.action_scales, ) ) - assert not hasattr(value, "kps") np.testing.assert_allclose( env._action_writes.buffer("joint_position"), actions * value.action_scales + value.default_angles, @@ -588,30 +570,14 @@ def test_numba_wbt_registry_uses_generic_manager_env() -> None: env = registry.make("g1-wbt-dance", mode="play", num_envs=1) assert isinstance(manager_cfg, WbtEnvCfg) - assert not hasattr(manager_cfg, "manager") - assert "motion_files" not in {field.name for field in fields(manager_cfg)} - assert not hasattr(manager_cfg, "tracked_body_names") - assert not hasattr(manager_cfg, "reference_body_name") - assert [term_field.name for term_field in fields(manager_cfg.commands)] == ["motion"] motion_command_cfg = manager_cfg.commands.motion assert isinstance(motion_command_cfg, WbtMotionCommandCfg) - assert not hasattr(motion_command_cfg, "adaptive_timestep_sampler") - assert motion_command_cfg.uniform_ratio == pytest.approx(0.1) - assert motion_command_cfg.alpha == pytest.approx(0.001) - assert motion_command_cfg.kernel_size == 1 - assert motion_command_cfg.kernel_lambda == pytest.approx(0.8) action_cfg = manager_cfg.actions.joint_position assert isinstance(action_cfg, JointPositionActionCfg) - assert not hasattr(manager_cfg, "values") - assert motion_command_cfg.motion_files == (str(_G1_MOTION_DIR / "dance"),) tracked_body_pos = env.sim_data.query("tracked_body_pos") assert isinstance(tracked_body_pos, BatchLinkPositionQuery) assert motion_command_cfg.tracked_body_names == tracked_body_pos.links assert motion_command_cfg.reference_body_name in motion_command_cfg.tracked_body_names - assert not motion_command_cfg.adaptive_sampling_enabled - assert not hasattr(action_cfg, "robot") - assert not hasattr(motion_command_cfg, "robot") - assert not hasattr(env, "value_manager") assert isinstance(manager_cfg.actions, ActionsCfg) assert isinstance(manager_cfg.actions.joint_position, JointPositionActionCfg) assert isinstance(manager_cfg.sim_reset, ManagerResetCfg) @@ -620,10 +586,7 @@ def test_numba_wbt_registry_uses_generic_manager_env() -> None: assert isinstance(manager_cfg.sim_reset.body_lin_vel, BodyLinVelResetCfg) assert isinstance(manager_cfg.sim_reset.body_rot_vel, BodyRotVelResetCfg) assert isinstance(manager_cfg.sim_reset.body_dof_pos, BodyDofPosResetCfg) - assert not hasattr(manager_cfg, "reset_noise") - assert manager_cfg.sim_reset.body_pos.noise == (0.05, 0.05, 0.01) - assert type(env) is ManagerEnv - assert not hasattr(env, "command_manager") + assert isinstance(env, ManagerEnv) assert isinstance(_motion_command(env), WbtMotionCommand) @@ -646,37 +609,6 @@ def test_wbt_manager_play_enables_sequential_playback() -> None: assert not cfg.commands.motion.adaptive_sampling_enabled -def test_single_clip_motion_dir_reproduces_file_clip(tmp_path) -> None: - """A corpus directory holding one clip assembles bit-for-bit like loading - that clip through the single-file path (the library's N=1 special case).""" - corpus_dir = tmp_path / "dance" - corpus_dir.mkdir() - (corpus_dir / _DANCE_MOTION.name).write_bytes(_DANCE_MOTION.read_bytes()) - - cfg = registry.make_env_config("g1-wbt-dance", mode="play") - assert isinstance(cfg, WbtEnvCfg) - motion_cfg = cfg.commands.motion - assert isinstance(motion_cfg, WbtMotionCommandCfg) - dir_cfg = replace( - cfg, - commands=replace(cfg.commands, motion=replace(motion_cfg, motion_files=(str(corpus_dir),))), - ) - - env = _make_numba_env(dir_cfg, num_envs=2) - motion = _motion_command(env) - direct = WbtMotionClip.create( - MotrixMotion(_DANCE_MOTION), - list(motion_cfg.joint_names), - motion_cfg.tracked_body_names, - motion_cfg.reference_body_name, - cfg.scene.objs.robot.base_link_name, - motion_cfg.extension_channels, - ) - for field in _CLIP_FRAME_FIELDS: - np.testing.assert_array_equal(getattr(motion.clip, field), getattr(direct, field)) - np.testing.assert_array_equal(motion.clip.frame_clip_end, motion.clip.joint_pos.shape[0] - 1) - - def test_wbt_robot_config_subclasses_isolate_nested_overrides() -> None: g1 = registry.make_env_config("g1-wbt-dance") another_g1 = registry.make_env_config("g1-wbt-dance") @@ -886,35 +818,6 @@ def _single_file_cfg(*, start_at_timestep_zero_prob: float | None = None) -> Wbt return replace(cfg, commands=replace(cfg.commands, motion=replace(motion, **replacements))) -_CLIP_FRAME_FIELDS = ( - "joint_pos", - "joint_vel", - "tracked_bodies_pos_w", - "tracked_bodies_quat_w", - "tracked_bodies_lin_vel_w", - "tracked_bodies_ang_vel_w", - "root_body_pos_w", - "root_body_quat_w", - "root_body_lin_vel_w", - "root_body_ang_vel_w", - "reference_body_pos_w", - "reference_body_quat_w", -) - - -def test_numba_wbt_multi_clip_corpus_reproduces_single_clip_arrays(tmp_path) -> None: - multi = _make_numba_env(_multi_clip_cfg(tmp_path), num_envs=2) - single = _make_numba_env(_single_file_cfg(), num_envs=2) - clip = _motion_command(multi).clip - single_clip = _motion_command(single).clip - - for field in _CLIP_FRAME_FIELDS: - np.testing.assert_array_equal(getattr(clip, field), getattr(single_clip, field)) - last = single_clip.joint_pos.shape[0] - 1 - np.testing.assert_array_equal(clip.frame_clip_end[:_DANCE_SPLIT], _DANCE_SPLIT - 1) - np.testing.assert_array_equal(clip.frame_clip_end[_DANCE_SPLIT:], last) - - def test_numba_wbt_multi_clip_wrap_rematerializes_sim_only(tmp_path) -> None: # Split after two frames: the first clip's final frame is adjacent to the # episode-start pose, so forcing a wrap there cannot diverge the physics. @@ -1149,4 +1052,4 @@ def test_numba_wbt_multi_clip_cfg_survives_pickle_round_trip(tmp_path) -> None: env = _make_numba_env(restored, num_envs=2) motion_term = _motion_command(env) assert motion_term.clip.frame_clip_end[_DANCE_SPLIT - 1] == _DANCE_SPLIT - 1 - assert motion_term.clip.frame_clip_end[-1] == 999 - 1 + assert motion_term.clip.frame_clip_end[-1] == motion_term.clip.joint_pos.shape[0] - 1 diff --git a/motrix_rl/tests/test_metrics.py b/motrix_rl/tests/test_metrics.py index 62748f03..d8da1740 100644 --- a/motrix_rl/tests/test_metrics.py +++ b/motrix_rl/tests/test_metrics.py @@ -3,13 +3,16 @@ import gzip import json +import queue import re import socket import threading import time import weakref +from contextlib import nullcontext from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from types import SimpleNamespace import numpy as np import pytest @@ -77,6 +80,24 @@ def ingest(): server.stop() +@pytest.fixture +def captured_http(monkeypatch): + """Capture serialized protocol requests without opening a socket.""" + requests = [] + + def urlopen(request, timeout): + requests.append( + { + "headers": {key.lower(): value for key, value in request.header_items()}, + "body": json.loads(gzip.decompress(request.data)), + } + ) + return nullcontext() + + monkeypatch.setattr(metrics_module.urllib.request, "urlopen", urlopen) + return requests + + @pytest.fixture(autouse=True) def _clean_env(monkeypatch): for name in ENV_VARS: @@ -258,16 +279,18 @@ def test_exit_writes_what_a_logger_still_holds(monkeypatch, tmp_path): def test_run_id_prefers_environment_and_is_sanitized(monkeypatch, tmp_path): - monkeypatch.setenv("MOTRIX_METRICS_URL", "http://127.0.0.1:9") + monkeypatch.setenv("MOTRIX_METRICS_JSONL", "1") run_dir = tmp_path / "runs" / "cartpole" / "skrl" / "torch" / "ppo" / "26-10-12_10-00-00-000000" derived = MetricsLogger.from_env(run_dir) derived.close() + derived.join(5) assert derived.run_id == "cartpole__skrl__torch__ppo__26-10-12_10-00-00-000000" monkeypatch.setenv("MOTRIX_METRICS_RUN_ID", "a b/c:" + "x" * 300 + "-tail") explicit = MetricsLogger.from_env(run_dir) explicit.close() + explicit.join(5) assert RUN_ID_PATTERN.match(explicit.run_id) assert len(explicit.run_id) == 200 assert explicit.run_id.endswith("x-tail") @@ -275,9 +298,11 @@ def test_run_id_prefers_environment_and_is_sanitized(monkeypatch, tmp_path): monkeypatch.setenv("MOTRIX_METRICS_RUN_ID", "ok.run_1-2") kept = MetricsLogger.from_env(run_dir) kept.close() + kept.join(5) assert kept.run_id == "ok.run_1-2" +@pytest.mark.integration def test_url_alone_pushes_without_writing_a_local_file(monkeypatch, tmp_path, ingest): monkeypatch.setenv("MOTRIX_METRICS_URL", ingest.url) logger = MetricsLogger.from_env(tmp_path) @@ -342,6 +367,7 @@ def _write_batch(self, batch): assert sink.batches == [1, 499] # the backlog goes out as one write and flush, not one per record +@pytest.mark.integration def test_from_env_pushes_gzip_batches_with_bearer_token(monkeypatch, tmp_path, ingest): monkeypatch.setenv("MOTRIX_METRICS_URL", ingest.url + "/") monkeypatch.setenv("MOTRIX_METRICS_JSONL", "1") @@ -376,6 +402,7 @@ def test_from_env_pushes_gzip_batches_with_bearer_token(monkeypatch, tmp_path, i assert "secret-token" not in (tmp_path / JSONL_FILENAME).read_text() +@pytest.mark.integration def test_server_errors_are_retried_without_duplicating_records(tmp_path): ingest = _Ingest(statuses=[500]) try: @@ -393,6 +420,7 @@ def test_server_errors_are_retried_without_duplicating_records(tmp_path): assert [record["seq"] for record in ingest.records()[2:]] == [record["seq"] for record in _jsonl(tmp_path)] +@pytest.mark.integration def test_service_unavailable_honours_retry_after(tmp_path): ingest = _Ingest(statuses=[503], headers={"Retry-After": "0"}) try: @@ -406,21 +434,62 @@ def test_service_unavailable_honours_retry_after(tmp_path): ingest.stop() -def test_records_queued_during_backoff_do_not_trigger_early_retries(tmp_path): - ingest = _Ingest(statuses=[503] * 10, headers={"Retry-After": "30"}) +def test_records_queued_during_backoff_do_not_trigger_early_retries(monkeypatch): + now = [0.0] + waits = queue.Queue() + posts = [] + + class ControlledCondition(threading.Condition): + def wait(self, timeout=None): + # Report every worker wait; notifications, not elapsed real time, drive it. + waits.put(timeout) + return super().wait() + + monkeypatch.setattr( + metrics_module, "time", SimpleNamespace(monotonic=lambda: now[0], time=time.time, time_ns=time.time_ns) + ) + http = HttpSink("http://metrics.invalid", batch_size=2, flush_budget=1) + http._cond = ControlledCondition() + + def unavailable(body): + posts.append(json.loads(gzip.decompress(body))) + return False, 30.0 # the transport's parsed Retry-After + + monkeypatch.setattr(http, "_post", unavailable) + # Start with a full batch so the first observed wait is the retry backoff. + for step in range(2): + http.put({"kind": "scalars", "step": step, "data": {"a": float(step)}}) + http.start(lambda: {}) try: - logger = _logger(tmp_path, ingest.url, flush_interval=60, batch_size=2) - logger.scalars({"a": 0.0}, step=0) - logger.scalars({"a": 1.0}, step=1) - _wait_until(lambda: len(ingest.requests) == 1) # now backing off for 30 s - for step in range(2, 50): # every put sees a full batch waiting - logger.scalars({"a": float(step)}, step=step) - logger.close() - logger.join(0.1) + assert waits.get(timeout=5) == 30 + for step in range(2, 50, 2): + with http._cond: + http.put({"kind": "scalars", "step": step, "data": {}}) + http.put({"kind": "scalars", "step": step + 1, "data": {}}) + # Acquiring the condition ensures the worker has entered wait(). Each + # notification must be processed and re-enter backoff, not retry HTTP. + assert waits.get(timeout=5) == 30 + assert len(posts) == 1 + with http._cond: + now[0] = 29 + http._cond.notify() + assert waits.get(timeout=5) == 1 + http.close() # flush deadline is now 30, equal to the retry deadline + assert waits.get(timeout=5) == 1 + with http._cond: + now[0] = 30 + http._cond.notify() + http.join(5) + assert not http._thread.is_alive() + assert len(posts) == 1 + assert [record["step"] for record in posts[0]["records"]] == [0, 1] finally: - ingest.stop() - - assert len(ingest.requests) == 1 + # Also stop the worker when an assertion fails: no sender leaks into + # subsequent tests or retries after monkeypatch restores the real clock. + http._flush_budget = 0 + http.close() + http.join(5) + assert not http._thread.is_alive() class _GarbageThenOk: @@ -455,6 +524,7 @@ def stop(self): self._sock.close() +@pytest.mark.integration def test_malformed_replies_are_retried_and_the_sink_keeps_running(tmp_path): server = _GarbageThenOk() try: @@ -471,6 +541,7 @@ def test_malformed_replies_are_retried_and_the_sink_keeps_running(tmp_path): assert sent == ["run_started", "warmup_done"] +@pytest.mark.integration def test_unexpected_sender_error_does_not_stop_delivery(tmp_path, ingest): calls = [] @@ -489,6 +560,7 @@ def heartbeat(): assert [record["data"]["name"] for record in ingest.records()] == ["warmup_done"] +@pytest.mark.integration def test_client_errors_drop_the_batch(tmp_path): ingest = _Ingest(statuses=[401, 401]) try: @@ -508,20 +580,26 @@ def test_client_errors_drop_the_batch(tmp_path): assert len(_jsonl(tmp_path)) == 2 +@pytest.mark.integration def test_unreachable_service_keeps_the_jsonl_and_gives_up_within_its_flush_budget(tmp_path): - # Emit cost under load is measured by bench/bench_live_metrics.py, not asserted here. - http = HttpSink("http://127.0.0.1:9", flush_interval=0.01, backoff=0.01, flush_budget=0.2) - logger = MetricsLogger("run-1", jsonl=JsonlSink(tmp_path / JSONL_FILENAME), http=http) - - for step in range(1000): - logger.scalars({"a": float(step)}, step=step) - logger.close() - logger.join(5) + # A bound, non-listening socket reserves an endpoint that cannot accept HTTP; + # unlike a hard-coded port it cannot accidentally address another service. + with socket.socket() as endpoint: + endpoint.bind(("127.0.0.1", 0)) + url = f"http://127.0.0.1:{endpoint.getsockname()[1]}" + http = HttpSink(url, flush_interval=0.01, backoff=0.01, flush_budget=0.2) + logger = MetricsLogger("run-1", jsonl=JsonlSink(tmp_path / JSONL_FILENAME), http=http) + + for step in range(1000): + logger.scalars({"a": float(step)}, step=step) + logger.close() + logger.join(5) assert not http._thread.is_alive() # gave up after its budget, well before the join timeout assert len(_jsonl(tmp_path)) == 1000 +@pytest.mark.integration def test_full_queue_drops_oldest_scalars_and_keeps_events(tmp_path, ingest): logger = _logger(tmp_path, ingest.url, flush_interval=60, max_queue=3) @@ -537,6 +615,7 @@ def test_full_queue_drops_oldest_scalars_and_keeps_events(tmp_path, ingest): assert len(_jsonl(tmp_path)) == 6 +@pytest.mark.integration def test_idle_sink_sends_heartbeats_only_over_http(tmp_path, ingest): logger = _logger(tmp_path, ingest.url, flush_interval=0.01, heartbeat_interval=0.01) @@ -554,25 +633,27 @@ def test_idle_sink_sends_heartbeats_only_over_http(tmp_path, ingest): @pytest.mark.parametrize("name", ["pipeline_pending.json", "pipeline_failed.json"]) -def test_orchestrator_fixtures_share_the_record_format(name, tmp_path, ingest): +def test_orchestrator_fixtures_share_the_record_format(name, captured_http): fixture = json.loads((FIXTURES / name).read_text()) - logger = MetricsLogger("run-1", http=HttpSink(ingest.url, "fixture-token")) + logger = MetricsLogger("run-1", http=HttpSink("http://metrics.invalid", "fixture-token")) logger.run("running") logger.close() logger.join(5) assert fixture["body"]["schema"] == SCHEMA - assert fixture["headers"] == {key: ingest.requests[0]["headers"][key] for key in fixture["headers"]} + assert fixture["headers"] == {key: captured_http[0]["headers"][key.lower()] for key in fixture["headers"]} for record in fixture["body"]["records"]: _assert_v1_record(record) assert record["writer"].endswith(":pipeline") @pytest.mark.parametrize("name", ["motrixlab_running.json", "motrixlab_scalars.json", "motrixlab_event.json"]) -def test_logger_reproduces_motrixlab_fixtures(name, tmp_path, ingest): +def test_logger_reproduces_motrixlab_fixtures(name, captured_http): fixture = json.loads((FIXTURES / name).read_text()) expected = fixture["body"]["records"] - logger = MetricsLogger(expected[0]["run_id"], http=HttpSink(ingest.url, "fixture-token", flush_interval=60)) + logger = MetricsLogger( + expected[0]["run_id"], http=HttpSink("http://metrics.invalid", "fixture-token", flush_interval=60) + ) for record in expected: data = dict(record["data"]) @@ -586,9 +667,9 @@ def test_logger_reproduces_motrixlab_fixtures(name, tmp_path, ingest): logger.close() logger.join(5) - assert len(ingest.requests) == 1 - request = ingest.requests[0] - assert fixture["headers"] == {key: request["headers"][key] for key in fixture["headers"]} + assert len(captured_http) == 1 + request = captured_http[0] + assert fixture["headers"] == {key: request["headers"][key.lower()] for key in fixture["headers"]} sent = request["body"]["records"] for record in sent: _assert_v1_record(record) diff --git a/motrix_rl/tests/test_runner.py b/motrix_rl/tests/test_runner.py index eeac7fa9..770a7053 100644 --- a/motrix_rl/tests/test_runner.py +++ b/motrix_rl/tests/test_runner.py @@ -88,6 +88,7 @@ def test_runner_train_creates_run_and_returns_result(demo_framework, tmp_path): assert not (result.run.run_dir / JSONL_FILENAME).exists() +@pytest.mark.integration def test_training_reports_live_metrics_but_play_does_not(demo_framework, monkeypatch, tmp_path): received = [] diff --git a/motrix_rl_builtin/tests/test_builtin_onnx_export.py b/motrix_rl_builtin/tests/test_builtin_onnx_export.py index 8ef957b2..f59790df 100644 --- a/motrix_rl_builtin/tests/test_builtin_onnx_export.py +++ b/motrix_rl_builtin/tests/test_builtin_onnx_export.py @@ -57,6 +57,7 @@ def _run_with_checkpoint(tmp_path: Path, config: TrainConfig, checkpoint: dict) return run +@pytest.mark.integration def test_fastsac_export_restores_actor_and_normalizer(tmp_path: Path) -> None: from motrix_rl_builtin.fastsac.buffer import EmpiricalNormalization from motrix_rl_builtin.fastsac.networks import Actor diff --git a/motrix_rl_builtin/tests/test_builtin_sim_backend.py b/motrix_rl_builtin/tests/test_builtin_sim_backend.py index 5926440f..bc4aa79f 100644 --- a/motrix_rl_builtin/tests/test_builtin_sim_backend.py +++ b/motrix_rl_builtin/tests/test_builtin_sim_backend.py @@ -56,9 +56,7 @@ def _make_env(sim_backend: str): ) else: env = Mock(spec=TorchEnv) - env.device = ( - torch.device("cuda", torch.cuda.current_device()) if torch.cuda.is_available() else torch.device("cpu") - ) + env.device = torch.device("cpu") state = TorchEnvState( data=None, obs=TorchObs(torch.zeros((_NUM_ENVS, _OBS_DIM), device=env.device)), @@ -285,6 +283,7 @@ def _collect_in_spawn(sim_backend: str, tmp_path) -> tuple[torch.Tensor, ...]: return tuple(tensor.clone() for tensor in views) +@pytest.mark.integration @pytest.mark.skipif( platform.machine().lower() not in {"amd64", "x86_64"}, reason="FastSAC async shared memory currently supports x86-64 only", diff --git a/motrix_rl_builtin/tests/test_fastsac_boot_panel.py b/motrix_rl_builtin/tests/test_fastsac_boot_panel.py index dd2182eb..5d90e215 100644 --- a/motrix_rl_builtin/tests/test_fastsac_boot_panel.py +++ b/motrix_rl_builtin/tests/test_fastsac_boot_panel.py @@ -129,9 +129,6 @@ def test_boot_panel_render_fits_terminal_height(tmp_path: Path, monkeypatch) -> probe = Console(width=100, height=20, force_terminal=True, file=open("/dev/null", "w")) rendered = ["".join(seg.text for seg in row) for row in probe.render_lines(panel.render({("collector", 0)}))] - # outer frame 2 lines; table region = 2 workers + 4 + gate 1 = 7; - # log region = 20 - 2 - 7 = 11; cell = 11 - 2 borders = 9 lines - assert panel.log_tail_lines == 9 assert len(rendered) == 20 # full-height frame, never taller assert any("ready" in ln for ln in rendered) assert any("booting" in ln for ln in rendered) @@ -159,7 +156,6 @@ def test_boot_panel_handoff_mode_renders_progress_and_gate(tmp_path: Path, monke # Boot phase: ready/booting statuses; the reserved gate line is blank. boot_text = "\n".join(boot) assert "ready" in boot_text and "booting" in boot_text and "c-line" in boot_text - assert "worker startup" not in boot_text # table title removed; task name is the frame title # Handoff phase: same frame height, every worker "starting", gate line filled. handoff_text = "\n".join(handoff) assert len(handoff) == len(boot) == 20 diff --git a/motrix_rl_builtin/tests/test_fastsac_collector.py b/motrix_rl_builtin/tests/test_fastsac_collector.py index 81a20579..6e7a0170 100644 --- a/motrix_rl_builtin/tests/test_fastsac_collector.py +++ b/motrix_rl_builtin/tests/test_fastsac_collector.py @@ -272,13 +272,16 @@ def test_collector_rejects_unsupported_device() -> None: resolve_collector_inference_device("meta") -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA device-index test requires a GPU") -def test_collector_rejects_unavailable_cuda_index() -> None: - invalid_device = f"cuda:{torch.cuda.device_count()}" +def test_collector_rejects_unavailable_cuda_index(monkeypatch) -> None: + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr(torch.cuda, "device_count", lambda: 1) + invalid_device = "cuda:1" with pytest.raises(RuntimeError, match="but only"): resolve_collector_inference_device(invalid_device) +@pytest.mark.integration +@pytest.mark.slow @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA collector test requires a GPU") def test_cuda_collector_uses_flat_weight_copy_and_reuses_staging() -> None: collector, source_actor, source_normalizer, weight_tx = _collector("cuda") @@ -331,6 +334,8 @@ def test_cuda_collector_uses_flat_weight_copy_and_reuses_staging() -> None: ) +@pytest.mark.integration +@pytest.mark.slow @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA collector test requires a GPU") def test_cuda_fp32_deterministic_matches_cpu() -> None: cpu_collector, _, _, _ = _collector("cpu") @@ -343,6 +348,8 @@ def test_cuda_fp32_deterministic_matches_cpu() -> None: torch.testing.assert_close(cuda_actions, cpu_actions, rtol=2e-4, atol=2e-4) +@pytest.mark.integration +@pytest.mark.slow @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA collector test requires a GPU") def test_compiled_cuda_stochastic_actions_change_and_stay_in_bounds() -> None: collector, _, _, _ = _collector("cuda", compile=True) diff --git a/motrix_rl_builtin/tests/test_fastsac_ddp_equivalence.py b/motrix_rl_builtin/tests/test_fastsac_ddp_equivalence.py index d6ba9ff9..4fc56302 100644 --- a/motrix_rl_builtin/tests/test_fastsac_ddp_equivalence.py +++ b/motrix_rl_builtin/tests/test_fastsac_ddp_equivalence.py @@ -13,7 +13,7 @@ from __future__ import annotations -import os +from datetime import timedelta import pytest import torch @@ -74,12 +74,12 @@ def _fixed_batch(rows: int, seed: int) -> dict: } -def _worker(rank: int, result_queue, seeds) -> None: +def _worker(rank: int, result_queue, seeds, rendezvous: str) -> None: + import traceback + import torch.distributed as dist - os.environ.update(MASTER_ADDR="127.0.0.1", MASTER_PORT=str(seeds["port"])) - dist.init_process_group("gloo", rank=rank, world_size=2) - import traceback + torch.set_num_threads(1) # gloo has no ReduceOp.AVG — emulate it (NCCL supports it in production) _orig_all_reduce = dist.all_reduce @@ -94,6 +94,7 @@ def _avg_all_reduce(tensor, op=None): dist.all_reduce = _avg_all_reduce try: + dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=2, timeout=timedelta(seconds=60)) agent = _make_agent(2, seeds["init"]) half = _fixed_batch(64, seeds["batch"]) # rank r sees half r of the SAME global batch @@ -117,31 +118,58 @@ def _avg_all_reduce(tensor, op=None): buffer = io.BytesIO() torch.save({k: v for k, v in agent.state_dict().items() if "optimizer" not in k}, buffer) - if rank == 0: - result_queue.put(buffer.getvalue()) + result_queue.put((rank, buffer.getvalue() if rank == 0 else None, None)) except Exception: - if rank == 0: - result_queue.put({"__error__": traceback.format_exc()}) + result_queue.put((rank, None, traceback.format_exc())) + raise finally: - dist.destroy_process_group() + if dist.is_initialized(): + dist.destroy_process_group() -def test_ddp_update_matches_single_process() -> None: +@pytest.mark.integration +@pytest.mark.slow +def test_ddp_update_matches_single_process(tmp_path) -> None: """2-rank half-batch + grad AVG == 1-rank full batch, per update step.""" - seeds = {"init": 1234, "batch": 42, "steps": 3, "port": 29711} - ctx = mp.get_context("spawn") - result_queue = ctx.Queue() - procs = [ctx.Process(target=_worker, args=(r, result_queue, seeds)) for r in range(2)] - for p in procs: - p.start() import io + from queue import Empty - payload = result_queue.get(timeout=120) - if isinstance(payload, dict) and "__error__" in payload: - pytest.fail("DDP worker crashed:\n" + payload["__error__"]) - ddp_state = torch.load(io.BytesIO(payload), weights_only=False) - for p in procs: - p.join(timeout=60) + seeds = {"init": 1234, "batch": 42, "steps": 3} + # FileStore accepts a nonexistent path and avoids shared ports entirely. + rendezvous = (tmp_path / "ddp-rendezvous").as_uri() + ctx = mp.get_context("spawn") + result_queue = ctx.Queue() + procs = [ctx.Process(target=_worker, args=(r, result_queue, seeds, rendezvous)) for r in range(2)] + started = [] + results = {} + try: + for process in procs: + process.start() + started.append(process) + for _ in procs: + try: + rank, payload, error = result_queue.get(timeout=120) + except Empty: + pytest.fail(f"DDP workers timed out; exit codes: {[p.exitcode for p in started]}") + assert error is None, f"DDP rank {rank} crashed:\n{error}" + results[rank] = payload + for process in started: + process.join(timeout=15) + assert not process.is_alive(), f"DDP worker {process.pid} did not exit" + assert process.exitcode == 0, f"DDP worker {process.pid} exited with {process.exitcode}" + assert set(results) == {0, 1} + ddp_state = torch.load(io.BytesIO(results[0]), weights_only=False) + finally: + for process in started: + if process.is_alive(): + process.terminate() + for process in started: + process.join(timeout=5) + if process.is_alive(): + process.kill() + process.join(timeout=5) + result_queue.close() + result_queue.join_thread() single = _make_agent(1, seeds["init"]) full = _fixed_batch(64, seeds["batch"]) diff --git a/motrix_rl_builtin/tests/test_fastsac_final_obs.py b/motrix_rl_builtin/tests/test_fastsac_final_obs.py index 6c5e1f14..09047991 100644 --- a/motrix_rl_builtin/tests/test_fastsac_final_obs.py +++ b/motrix_rl_builtin/tests/test_fastsac_final_obs.py @@ -268,6 +268,7 @@ def _producer(ring, count): time.sleep(0.0001) +@pytest.mark.integration def test_spawned_producer_and_slow_consumer_preserve_slot_payloads(): ring = _ring(capacity=5) count = 100 diff --git a/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py b/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py index 500ea670..94fc15ca 100644 --- a/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py +++ b/motrix_rl_builtin/tests/test_fastsac_ipc_ring.py @@ -28,6 +28,8 @@ CAPACITY, N_ENV, OBS, CRI, ACT = 5, 3, 4, 6, 2 FEAT = OBS + CRI + ACT + 3 +pytestmark = [pytest.mark.integration, pytest.mark.slow] + cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="IPC ring requires a GPU") diff --git a/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py b/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py index 0f4e553f..30a315c9 100644 --- a/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py +++ b/motrix_rl_builtin/tests/test_fastsac_multi_final_obs.py @@ -156,7 +156,16 @@ def test_staggered_collectors_backpressure_and_wrap_preserve_final_rows(): @pytest.mark.parametrize("n_steps", [1, 3]) -@pytest.mark.parametrize("path", ["cpu", "cuda-staged", "cuda-mixed", "cuda-ipc"]) +@pytest.mark.parametrize( + "path", + [ + "cpu", + *[ + pytest.param(path, marks=[pytest.mark.integration, pytest.mark.slow]) + for path in ("cuda-staged", "cuda-mixed", "cuda-ipc") + ], + ], +) def test_multi_collector_bootstraps_at_each_envs_final_boundary(n_steps, path): if path != "cpu" and not torch.cuda.is_available(): pytest.skip("CUDA unavailable") diff --git a/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py b/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py index c1aebf84..e9f36676 100644 --- a/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py +++ b/motrix_rl_builtin/tests/test_fastsac_pipeline_equivalence.py @@ -54,7 +54,7 @@ def test_generation_merge_matches_single_ring_byte_for_byte() -> None: single = _make_learner([SharedTransitionRing(16, envs, _OBS, _COBS, _ACT)], single_extends) for fields in whole: assert single.rings[0].push(*fields) - single.drain() + assert single.drain() == batches dual_extends: list = [] rings = [SharedTransitionRing(16, envs // shards, _OBS, _COBS, _ACT) for _ in range(shards)] @@ -64,8 +64,7 @@ def test_generation_merge_matches_single_ring_byte_for_byte() -> None: for fields in whole: for r in range(shards): assert rings[r].push(*shard(fields, r)) - while dual.drain() == 0: - pass + assert dual.drain() == batches assert len(single_extends) == len(dual_extends) for a, b in zip(single_extends, dual_extends): @@ -97,9 +96,8 @@ def make_rb(): assert single.rings[0].push(*fields) for r in range(shards): assert rings[r].push(*tuple(f[r * (envs // shards) : (r + 1) * (envs // shards)] for f in fields)) - single.drain() - while dual.drain() == 0: - pass + assert single.drain() == batches + assert dual.drain() == batches for name in ("observations", "actions", "rewards", "dones", "truncations"): torch.testing.assert_close(getattr(single_rb, name), getattr(dual_rb, name)) diff --git a/motrix_rl_builtin/tests/test_fastsac_trainer_selection.py b/motrix_rl_builtin/tests/test_fastsac_trainer_selection.py index 0463f2ec..cc5644b2 100644 --- a/motrix_rl_builtin/tests/test_fastsac_trainer_selection.py +++ b/motrix_rl_builtin/tests/test_fastsac_trainer_selection.py @@ -30,7 +30,7 @@ def test_motrix_fastsac_asynchronous_switches_trainer(tmp_path): "g1-walk-flat", "motrix", "fastsac", - overrides=["algo.trainer.async_options.utd_mode=learner_bound"], + overrides=["algo.asynchronous=true", "algo.trainer.async_options.utd_mode=learner_bound"], ) sync_cfg = _compose_task_option( "g1-walk-flat", @@ -43,9 +43,6 @@ def test_motrix_fastsac_asynchronous_switches_trainer(tmp_path): assert sync_cfg.algo.asynchronous is False assert isinstance(async_cfg.algo.trainer.async_options, FastSacAsyncOptionsCfg) assert async_cfg.algo.trainer.async_options.utd_mode == "learner_bound" - assert async_cfg.algo.trainer.async_options.collector_inference_device == "cuda" - assert async_cfg.algo.trainer.async_options.collector_compile is True - assert async_cfg.algo.trainer.async_options.collector_amp is True for index, (task_cfg, trainer_type) in enumerate(((sync_cfg, SyncTrainer), (async_cfg, AsyncTrainer))): run = runs.create_run_context( diff --git a/motrix_rl_rslrl/tests/test_rslrl_onnx_export.py b/motrix_rl_rslrl/tests/test_rslrl_onnx_export.py index 2fe07e50..e8654420 100644 --- a/motrix_rl_rslrl/tests/test_rslrl_onnx_export.py +++ b/motrix_rl_rslrl/tests/test_rslrl_onnx_export.py @@ -59,6 +59,7 @@ def _run_with_checkpoint(tmp_path: Path, config: TrainConfig, checkpoint: dict) return run +@pytest.mark.integration def test_rslrl_torch_export_uses_framework_normalizer(tmp_path: Path) -> None: from rsl_rl.models import MLPModel diff --git a/motrix_rl_rslrl/tests/test_rslrl_ppo.py b/motrix_rl_rslrl/tests/test_rslrl_ppo.py index 78bd6f61..32f3c058 100644 --- a/motrix_rl_rslrl/tests/test_rslrl_ppo.py +++ b/motrix_rl_rslrl/tests/test_rslrl_ppo.py @@ -89,5 +89,5 @@ def test_trainer_seed_covers_python_numpy_and_torch(monkeypatch, seed): trainer._set_seed(seed) - expected = [] if seed is None else [("python", seed), ("numpy", seed), ("torch", seed), ("cuda", seed)] - assert calls == expected + expected = set() if seed is None else {("python", seed), ("numpy", seed), ("torch", seed), ("cuda", seed)} + assert set(calls) == expected diff --git a/motrix_rl_rslrl/tests/test_rslrl_sim_backend.py b/motrix_rl_rslrl/tests/test_rslrl_sim_backend.py index 0e05c5c5..6acca46f 100644 --- a/motrix_rl_rslrl/tests/test_rslrl_sim_backend.py +++ b/motrix_rl_rslrl/tests/test_rslrl_sim_backend.py @@ -43,9 +43,7 @@ def _make_env(sim_backend: str): ) else: env = Mock(spec=TorchEnv) - env.device = ( - torch.device("cuda", torch.cuda.current_device()) if torch.cuda.is_available() else torch.device("cpu") - ) + env.device = torch.device("cpu") state = TorchEnvState( data=None, obs=TorchObs(torch.zeros((_NUM_ENVS, _OBS_DIM), device=env.device)), diff --git a/motrix_rl_skrl/tests/test_skrl_onnx_export.py b/motrix_rl_skrl/tests/test_skrl_onnx_export.py index 530d9dc5..1c0c2199 100644 --- a/motrix_rl_skrl/tests/test_skrl_onnx_export.py +++ b/motrix_rl_skrl/tests/test_skrl_onnx_export.py @@ -6,6 +6,7 @@ import numpy as np import onnx +import onnxruntime as ort import pytest import torch from hydra import compose, initialize_config_dir @@ -59,6 +60,7 @@ def _run_with_checkpoint(tmp_path: Path, config: TrainConfig, checkpoint: dict) return run +@pytest.mark.integration def test_skrl_torch_export_bakes_observation_preprocessor(tmp_path: Path) -> None: config = _train_config( "cartpole/skrl.ppo", @@ -92,7 +94,21 @@ def test_skrl_torch_export_bakes_observation_preprocessor(tmp_path: Path) -> Non assert result.report.output_spec.shape == (None, 2) assert result.report.parity.max_abs_error < 1e-5 model = onnx.load(result.path) - assert any(node.op_type == "Clip" for node in model.graph.node) + # Exercise the scaler's clipping behavior, not an ONNX operator spelling: + # observations beyond either threshold must match their boundary values. + from motrix_rl_skrl.torch.export import _build_reference, _extract_weights + + reference = _build_reference(_extract_weights(config.algo, policy, normalizer)) + mean = normalizer["running_mean"].numpy().astype(np.float32) + std = np.sqrt(normalizer["running_variance"].numpy().astype(np.float32) + 1e-8) + directions = np.asarray([[1, -1, 1], [-1, 1, -1]], dtype=np.float32) + boundary = mean + directions * 5.0 * std + extreme = mean + directions * 1e6 * std + expected = reference(extreme) + np.testing.assert_allclose(expected, reference(boundary), atol=1e-6, rtol=1e-5) + session = ort.InferenceSession(str(result.path), providers=["CPUExecutionProvider"]) + actual = session.run(None, {session.get_inputs()[0].name: extreme})[0] + np.testing.assert_allclose(actual, expected, atol=1e-6, rtol=1e-5) properties = {item.key: item.value for item in model.metadata_props} assert properties["rllib"] == "skrl" assert properties["train_backend"] == "torch" diff --git a/motrix_rl_skrl/tests/test_skrl_sim_backend.py b/motrix_rl_skrl/tests/test_skrl_sim_backend.py index 7e3ba541..81457de1 100644 --- a/motrix_rl_skrl/tests/test_skrl_sim_backend.py +++ b/motrix_rl_skrl/tests/test_skrl_sim_backend.py @@ -45,9 +45,7 @@ def _make_env(sim_backend: str): ) else: env = Mock(spec=TorchEnv) - env.device = ( - torch.device("cuda", torch.cuda.current_device()) if torch.cuda.is_available() else torch.device("cpu") - ) + env.device = torch.device("cpu") state = TorchEnvState( data=None, obs=TorchObs(torch.zeros((_NUM_ENVS, _OBS_DIM), device=env.device)), diff --git a/motrix_robots/tests/test_robots_package.py b/motrix_robots/tests/test_robots_package.py index 24948260..64fccf15 100644 --- a/motrix_robots/tests/test_robots_package.py +++ b/motrix_robots/tests/test_robots_package.py @@ -24,13 +24,10 @@ def check_asset_access(event, args): assert '/motrix_robots/common' not in path, (event, path) sys.addaudithook(check_asset_access) - from motrix_env_core import registry import motrix_robots from motrix_robots.unitree import UnitreeGo2Robot import motrix_robots.registration UnitreeGo2Robot() - assert not registry._robots - assert not registry._robots_discovered """ ), ], diff --git a/tests/test_bench_duration.py b/tests/test_bench_duration.py index 4aeff8de..ec72c9f7 100644 --- a/tests/test_bench_duration.py +++ b/tests/test_bench_duration.py @@ -80,6 +80,7 @@ def stalled_worker(connection, config): connection.close() +@pytest.mark.integration def test_startup_timeout_reaps_spawn_workers(monkeypatch): processes = install_test_context(monkeypatch, stalled_worker) monkeypatch.setattr(duration, "STARTUP_TIMEOUT", 0.2) @@ -117,6 +118,7 @@ def get_context(method): return processes +@pytest.mark.integration def test_spawn_workers_share_window_and_aggregate_counts(monkeypatch): processes = install_test_context(monkeypatch, fake_worker) results = duration.run_duration(_args(), 5) @@ -131,6 +133,7 @@ def test_spawn_workers_share_window_and_aggregate_counts(monkeypatch): assert all(process.exitcode == 0 for process in processes) +@pytest.mark.integration def test_worker_failure_propagates_and_reaps_processes(monkeypatch): processes = install_test_context(monkeypatch, failing_worker) with pytest.raises(RuntimeError, match="worker exited"): diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index 8c2561bb..aae30a87 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -6,8 +6,8 @@ import pytest from hydra import compose, initialize_config_dir from hydra.core.global_hydra import GlobalHydra -from motrix_rl_skrl.config import SkrlCfg +from motrix_rl import frameworks from motrix_rl.cli import to_typed_config from motrix_rl.config import OnnxExportConfig, TrainConfig from motrix_rl.plugins import load_plugins @@ -28,22 +28,13 @@ def _compose(config_name: str, overrides: list[str]): return compose(config_name=config_name, overrides=overrides) -def test_train_defaults(): +def test_default_train_config_composes_to_selected_provider_type(): cfg = _compose("train", []) - assert cfg.task.env == "cartpole" - assert cfg.task.rllib == "skrl" - assert cfg.task.algo == "ppo" - assert cfg.num_envs == 2048 - assert cfg.play_num_envs == 16 - assert cfg.seed == 42 - assert cfg.logging.backend == "tensorboard" - assert cfg.logging.interval == 100 - assert cfg.checkpoint.interval == 0 typed_cfg = to_typed_config(cfg, TrainConfig) + assert isinstance(typed_cfg, TrainConfig) - assert typed_cfg.task.env == "cartpole" - assert isinstance(typed_cfg.algo, SkrlCfg) + assert isinstance(typed_cfg.algo, frameworks.get_config_type(typed_cfg.task.rllib, typed_cfg.task.algo)) def test_train_scalar_overrides():