Some checks failed
nightly / Test against latest dependencies (py3.10) (push) Has been cancelled
nightly / Test against latest dependencies (py3.13) (push) Has been cancelled
tests / tests (3.13, locked) (push) Has been cancelled
tests / tests (3.13, unlocked) (push) Has been cancelled
tests / pyright (3.10) (push) Has been cancelled
tests / lint-format (push) Has been cancelled
tests / tests (3.10, locked) (push) Has been cancelled
tests / tests (3.11, locked) (push) Has been cancelled
tests / tests (3.12, locked) (push) Has been cancelled
tests / pyright (3.11) (push) Has been cancelled
tests / pyright (3.12) (push) Has been cancelled
tests / pyright (3.13) (push) Has been cancelled
tests / ty-check (3.10) (push) Has been cancelled
tests / ty-check (3.11) (push) Has been cancelled
tests / ty-check (3.12) (push) Has been cancelled
tests / ty-check (3.13) (push) Has been cancelled
tests / stubs (push) Has been cancelled
tests / smoke-test (push) Has been cancelled
Docker / check_paths (push) Has been cancelled
docs / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Upstream: https://github.com/michaelgillett/mjlab Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047 Upstream-Branch: main
270 lines
6.3 KiB
Python
270 lines
6.3 KiB
Python
"""Tests for the single-accumulator BaseViewer in base_alt.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
from mjlab.viewer.base import BaseViewer
|
|
|
|
|
|
class FakeViewer(BaseViewer):
|
|
"""Minimal concrete viewer for testing."""
|
|
|
|
def __init__(self, step_dt: float = 0.01, frame_rate: float = 60.0):
|
|
env = MagicMock()
|
|
env.unwrapped.step_dt = step_dt
|
|
env.cfg.viewer = MagicMock()
|
|
super().__init__(env, MagicMock(return_value=MagicMock()), frame_rate=frame_rate)
|
|
self.sim_step_count = 0
|
|
self.render_count = 0
|
|
self._last_tick_time = time.perf_counter()
|
|
|
|
def setup(self) -> None: ...
|
|
|
|
def sync_env_to_viewer(self) -> None:
|
|
self.render_count += 1
|
|
|
|
def sync_viewer_to_env(self) -> None: ...
|
|
def close(self) -> None: ...
|
|
def is_running(self) -> bool:
|
|
return True
|
|
|
|
def _execute_step(self) -> bool:
|
|
self.sim_step_count += 1
|
|
self._step_count += 1
|
|
self._stats_steps += 1
|
|
return True
|
|
|
|
def inject_tick(self, dt: float) -> bool:
|
|
"""Call tick() with a controlled dt."""
|
|
now = time.perf_counter()
|
|
self._last_tick_time = now - dt
|
|
return self.tick()
|
|
|
|
|
|
# Physics stepping.
|
|
|
|
|
|
def test_stepping():
|
|
"""Physics steps match sim-time budget."""
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.01)
|
|
assert v.sim_step_count == 1
|
|
|
|
v.inject_tick(dt=0.03)
|
|
assert v.sim_step_count == 4
|
|
|
|
v.inject_tick(dt=0.0)
|
|
assert v.sim_step_count == 4
|
|
|
|
|
|
def test_accumulator_carries():
|
|
"""Fractional budget carries across ticks."""
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.01)
|
|
assert v.sim_step_count == 1
|
|
|
|
v.inject_tick(dt=0.015)
|
|
assert v.sim_step_count == 2 # 0.015 budget, 1 step, 0.005 carry
|
|
|
|
v.inject_tick(dt=0.015)
|
|
assert v.sim_step_count == 4 # 0.005+0.015=0.02, 2 steps
|
|
|
|
|
|
def test_high_speed():
|
|
v = FakeViewer(step_dt=0.01, frame_rate=60.0)
|
|
v._speed_index = v.SPEED_MULTIPLIERS.index(8.0)
|
|
v._time_multiplier = 8.0
|
|
v.inject_tick(dt=1.0 / 60.0)
|
|
assert v.sim_step_count == 13
|
|
|
|
|
|
def test_slow_speed():
|
|
v = FakeViewer(step_dt=0.01, frame_rate=60.0)
|
|
v._speed_index = 0
|
|
v._time_multiplier = 1 / 32
|
|
for _ in range(19):
|
|
v.inject_tick(dt=1.0 / 60.0)
|
|
assert v.sim_step_count == 0
|
|
for _ in range(3):
|
|
v.inject_tick(dt=1.0 / 60.0)
|
|
assert v.sim_step_count >= 1
|
|
|
|
|
|
# Render timing.
|
|
|
|
|
|
def test_render_at_frame_rate():
|
|
v = FakeViewer(step_dt=0.01, frame_rate=60.0)
|
|
assert v.inject_tick(dt=0.001) is True # First tick renders.
|
|
assert v.inject_tick(dt=0.001) is False # Too soon.
|
|
assert v.inject_tick(dt=1.0 / 60.0) is True # Frame time elapsed.
|
|
|
|
|
|
# Pause and resume.
|
|
|
|
|
|
def test_pause_stops_physics():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.02)
|
|
before = v.sim_step_count
|
|
|
|
v.pause()
|
|
v.inject_tick(dt=0.5)
|
|
assert v.sim_step_count == before
|
|
|
|
|
|
def test_resume_no_burst():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.03)
|
|
count_before = v.sim_step_count
|
|
|
|
v.pause()
|
|
v._last_error = "some error"
|
|
|
|
v.resume()
|
|
assert v._last_error is None
|
|
assert v._sim_budget == 0.0
|
|
|
|
v.inject_tick(dt=0.01)
|
|
assert v.sim_step_count - count_before == 1
|
|
|
|
|
|
# Single step.
|
|
|
|
|
|
def test_single_step_while_paused():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.pause()
|
|
|
|
v.request_single_step()
|
|
v.inject_tick(dt=0.0)
|
|
|
|
assert v.sim_step_count == 1
|
|
assert v._is_paused
|
|
|
|
|
|
def test_single_step_ignored_when_running():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.02)
|
|
before = v.sim_step_count
|
|
|
|
v.request_single_step()
|
|
v.inject_tick(dt=0.02)
|
|
assert v.sim_step_count > before
|
|
|
|
|
|
# Error recovery.
|
|
|
|
|
|
def test_error_pauses():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v._execute_step = BaseViewer._execute_step.__get__(v, FakeViewer) # type: ignore[attr-defined]
|
|
v.policy = MagicMock(side_effect=RuntimeError("test error"))
|
|
|
|
v.inject_tick(dt=0.02)
|
|
|
|
assert v._is_paused
|
|
assert v._last_error is not None
|
|
assert "test error" in v._last_error
|
|
|
|
|
|
def test_reset_clears():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.02)
|
|
v._last_error = "err"
|
|
|
|
v.reset_environment()
|
|
assert v._step_count == 0
|
|
assert v._sim_budget == 0.0
|
|
assert v._last_error is None
|
|
|
|
|
|
def test_reset_calls_policy_reset():
|
|
"""reset_environment() calls policy.reset() if available."""
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.policy = MagicMock()
|
|
v.policy.reset = MagicMock()
|
|
|
|
v.reset_environment()
|
|
v.policy.reset.assert_called_once()
|
|
|
|
|
|
def test_reset_without_policy_reset():
|
|
"""reset_environment() works fine when policy has no reset method."""
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.policy = lambda obs: obs # plain callable, no reset attribute
|
|
|
|
v.reset_environment() # should not raise
|
|
|
|
|
|
# Formatting and status.
|
|
|
|
|
|
def test_format_speed():
|
|
assert BaseViewer._format_speed(1.0) == "1x"
|
|
assert BaseViewer._format_speed(0.5) == "1/2x"
|
|
assert BaseViewer._format_speed(0.25) == "1/4x"
|
|
assert BaseViewer._format_speed(1 / 32) == "1/32x"
|
|
|
|
|
|
def test_status_snapshot():
|
|
v = FakeViewer(step_dt=0.01)
|
|
v._fps = 60.0
|
|
v._sps = 50.0
|
|
v._last_error = "err"
|
|
|
|
status = v.get_status()
|
|
assert abs(status.actual_realtime - 0.5) < 1e-10
|
|
assert status.speed_label == "1x"
|
|
assert status.last_error == "err"
|
|
|
|
|
|
def test_capped_clears_each_tick():
|
|
"""Capped resets to False each tick; only True when deadline hit."""
|
|
v = FakeViewer(step_dt=0.01)
|
|
v.inject_tick(dt=0.02)
|
|
assert not v._was_capped
|
|
|
|
# Force capped state, then verify next tick clears it.
|
|
v._was_capped = True
|
|
v.inject_tick(dt=0.02)
|
|
assert not v._was_capped
|
|
|
|
|
|
def test_capped_false_when_no_remaining_budget():
|
|
"""Deadline hit with no remaining budget is NOT capped (transient stall)."""
|
|
v = FakeViewer(step_dt=0.01, frame_rate=60.0)
|
|
# dt=0.01 at 1x: budget=0.01, exactly 1 step, budget goes to 0.
|
|
# Even if deadline fires (via GC), no remaining work to drop.
|
|
v.inject_tick(dt=0.01)
|
|
assert not v._was_capped
|
|
|
|
|
|
# Spiral protection.
|
|
|
|
|
|
def test_no_spiral():
|
|
v = FakeViewer(step_dt=0.01, frame_rate=60.0)
|
|
v._speed_index = v.SPEED_MULTIPLIERS.index(8.0)
|
|
v._time_multiplier = 8.0
|
|
|
|
original = v._execute_step
|
|
|
|
def slow_step() -> bool:
|
|
time.sleep(0.005)
|
|
return original()
|
|
|
|
v._execute_step = slow_step # type: ignore[assignment]
|
|
|
|
start = time.perf_counter()
|
|
v.inject_tick(dt=1.0 / 60.0)
|
|
elapsed = time.perf_counter() - start
|
|
|
|
assert elapsed < 0.05
|
|
assert v.sim_step_count < 13
|
|
assert v.sim_step_count >= 1
|
|
# Capped because slow steps + large budget means remaining work was dropped.
|
|
assert v._was_capped
|