mjlab/tests/test_viewer_tick.py
Upstream Snapshot 32a241c28f
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
Import upstream snapshot c19f713c415a699a79d71cd96aa13c3104a05047
Upstream: https://github.com/michaelgillett/mjlab
Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047
Upstream-Branch: main
2026-08-28 15:42:17 +08:00

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