"""Input validation regression tests for PR #85 fixes (T7, T9, T10).

These guard against silent data-integrity bugs and process-killing MuJoCo
aborts that were caught by autonomous local testing on PR #85.
"""

import pytest

pytest.importorskip("mujoco")
import numpy as np  # noqa: E402

from strands_robots.simulation.mujoco.backend import _can_render  # noqa: E402

requires_gl = pytest.mark.skipif(
    not _can_render(),
    reason="No GL context available (headless CI without EGL/OSMesa)",
)

from strands_robots.simulation.mujoco.simulation import Simulation  # noqa: E402


@pytest.fixture
def sim_with_world():
    """A minimal simulation with an empty world for validation tests."""
    sim = Simulation()
    sim.create_world()
    yield sim
    sim.destroy()


@pytest.fixture
def sim_with_robot():
    """A simulation with a single robot for physics-validation tests."""
    sim = Simulation()
    sim.create_world()
    # Use a built-in registry robot - no network I/O
    res = sim.add_robot(name="panda", data_config="panda")
    if res["status"] != "success":
        pytest.skip(f"panda not available: {res['content'][0]['text']}")
    sim.reset()
    yield sim
    sim.destroy()


# T9: step validation


class TestStepValidation:
    def test_step_negative_errors(self, sim_with_world):
        """step(n_steps=-5) must error and NOT decrement step_count.

        The wording is the shared cross-backend one
        (:func:`~strands_robots.utils.non_negative_whole_number_error`) rather
        than this backend's own: Newton and Isaac state the same refusal
        verbatim, which is what
        ``tests/simulation/test_step_count_domain_across_backends.py`` pins. The
        refusal itself, and the untouched ``step_count``, are unchanged.
        """
        initial = sim_with_world._world.step_count
        res = sim_with_world.step(n_steps=-5)
        assert res["status"] == "error"
        assert "n_steps must be a non-negative whole number" in res["content"][0]["text"]
        assert sim_with_world._world.step_count == initial, "step_count must not change on rejected call"

    def test_step_zero_is_noop(self, sim_with_world):
        """step(n_steps=0) is a successful no-op."""
        initial = sim_with_world._world.step_count
        res = sim_with_world.step(n_steps=0)
        assert res["status"] == "success"
        assert "no-op" in res["content"][0]["text"].lower()
        assert sim_with_world._world.step_count == initial

    def test_step_positive_still_works(self, sim_with_world):
        """Baseline: non-negative n_steps continues to work."""
        res = sim_with_world.step(n_steps=3)
        assert res["status"] == "success"
        assert sim_with_world._world.step_count == 3

    def test_step_float_is_coerced_to_int(self, sim_with_world):
        """A non-int but coercible n_steps (e.g. 3.0) is accepted as int(3)."""
        res = sim_with_world.step(n_steps=3.0)
        assert res["status"] == "success"
        assert sim_with_world._world.step_count == 3

    def test_step_non_coercible_type_errors(self, sim_with_world):
        """A non-numeric n_steps errors and names the value it was given.

        Names the VALUE rather than its type name, which is the convention of
        every helper in this family (``got {value!r}``) and the more actionable
        half: a caller who passed the wrong variable learns which one it was.
        """
        initial = sim_with_world._world.step_count
        res = sim_with_world.step(n_steps="not-a-number")
        assert res["status"] == "error"
        msg = res["content"][0]["text"]
        assert "n_steps must be a non-negative whole number" in msg
        assert "'not-a-number'" in msg
        assert sim_with_world._world.step_count == initial, "step_count must not change on rejected call"

    def test_step_exceeds_max_per_call_errors(self, sim_with_world):
        """n_steps above the per-call ceiling is rejected without stepping."""
        initial = sim_with_world._world.step_count
        over = sim_with_world._MAX_STEPS_PER_CALL + 1
        res = sim_with_world.step(n_steps=over)
        assert res["status"] == "error"
        assert "exceeds max" in res["content"][0]["text"]
        assert sim_with_world._world.step_count == initial, "step_count must not change on rejected call"


# Raycast zero-direction guard


class TestRaycastValidation:
    def test_zero_direction_errors_not_crash(self, sim_with_robot):
        """raycast with zero direction used to abort the interpreter. Now errors cleanly."""
        res = sim_with_robot.raycast(origin=[0, 0, 1], direction=[0, 0, 0])
        assert res["status"] == "error"
        assert "zero-length" in res["content"][0]["text"].lower()

    def test_wrong_length_direction_errors(self, sim_with_robot):
        res = sim_with_robot.raycast(origin=[0, 0, 1], direction=[0, 0])
        assert res["status"] == "error"
        assert "3 elements" in res["content"][0]["text"]

    def test_wrong_length_origin_errors(self, sim_with_robot):
        res = sim_with_robot.raycast(origin=[0, 0], direction=[0, 0, 1])
        assert res["status"] == "error"
        assert "3 elements" in res["content"][0]["text"]

    def test_valid_raycast_still_works(self, sim_with_robot):
        res = sim_with_robot.raycast(origin=[0, 0, 5], direction=[0, 0, -1])
        assert res["status"] == "success"

    def test_multi_raycast_zero_direction_refuses_the_batch(self, sim_with_robot):
        """A zero-length direction refuses the batch instead of half-casting it.

        Reporting the other two rays under ``status: "success"`` gave ray[1] the
        same ``distance: None`` a genuine miss carries, so the bearing that was
        never cast read as clear.
        """
        res = sim_with_robot.multi_raycast(
            origin=[0, 0, 5],
            directions=[[0, 0, -1], [0, 0, 0], [1, 0, -1]],
        )
        assert res["status"] == "error"
        invalid = res["content"][1]["json"]["invalid_directions"]
        assert [entry["index"] for entry in invalid] == [1]
        assert "zero-length" in invalid[0]["error"]

    @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
    def test_nonfinite_direction_errors(self, sim_with_robot, bad):
        """A nan/inf direction survives the zero-length guard (``nan < 1e-10`` is
        False) and would poison the normalized vector fed to mj_ray. Reject it."""
        res = sim_with_robot.raycast(origin=[0, 0, 1], direction=[bad, 0.0, 0.0])
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"].lower()
        assert "direction" in res["content"][0]["text"]

    @pytest.mark.parametrize("bad", [float("nan"), float("inf")])
    def test_nonfinite_origin_errors(self, sim_with_robot, bad):
        res = sim_with_robot.raycast(origin=[bad, 0.0, 1.0], direction=[0, 0, -1])
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"].lower()
        assert "origin" in res["content"][0]["text"]

    def test_non_numeric_origin_errors_not_raises(self, sim_with_robot):
        """A non-numeric element must return a structured error, not raise
        ValueError from np.array(dtype=float64) past the tool contract."""
        res = sim_with_robot.raycast(origin=["a", "b", "c"], direction=[0, 0, -1])
        assert res["status"] == "error"
        assert "number" in res["content"][0]["text"].lower()

    def test_non_numeric_direction_errors_not_raises(self, sim_with_robot):
        res = sim_with_robot.raycast(origin=[0, 0, 1], direction=["x", 0, 0])
        assert res["status"] == "error"
        assert "number" in res["content"][0]["text"].lower()

    def test_multi_raycast_non_numeric_origin_errors_not_raises(self, sim_with_robot):
        res = sim_with_robot.multi_raycast(origin=["x", 0, 1], directions=[[0, 0, -1]])
        assert res["status"] == "error"
        assert "number" in res["content"][0]["text"].lower()

    @pytest.mark.parametrize("bad", [float("nan"), float("inf")])
    def test_multi_raycast_nonfinite_origin_errors(self, sim_with_robot, bad):
        res = sim_with_robot.multi_raycast(origin=[bad, 0, 1], directions=[[0, 0, -1]])
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"].lower()

    def test_multi_raycast_nonfinite_direction_refuses_the_batch(self, sim_with_robot):
        """A nan direction refuses the batch, naming its index, and never reaches
        mj_ray as a poisoned vector."""
        res = sim_with_robot.multi_raycast(
            origin=[0, 0, 5],
            directions=[[0, 0, -1], [float("nan"), 0.0, 0.0], [1, 0, -1]],
        )
        assert res["status"] == "error"
        invalid = res["content"][1]["json"]["invalid_directions"]
        assert [entry["index"] for entry in invalid] == [1]
        assert "finite" in invalid[0]["error"].lower()


class TestApplyForceValidation:
    def test_missing_both_force_and_torque_errors(self, sim_with_robot):
        """apply_force(body='link1') with no force/torque must error, not silent success."""
        res = sim_with_robot.apply_force(body_name="link1")
        assert res["status"] == "error"
        assert "at least one" in res["content"][0]["text"].lower()

    def test_explicit_zero_force_still_clears_latched(self, sim_with_robot):
        """Regression: apply_force(body, force=[0,0,0]) is the documented way to clear."""
        # First latch a force
        r1 = sim_with_robot.apply_force(body_name="link1", force=[10, 0, 0])
        assert r1["status"] == "success"
        # Then clear with explicit zero - this MUST remain valid
        r2 = sim_with_robot.apply_force(body_name="link1", force=[0, 0, 0])
        assert r2["status"] == "success"

    def test_wrong_length_force_errors(self, sim_with_robot):
        res = sim_with_robot.apply_force(body_name="link1", force=[1, 2])
        assert res["status"] == "error"
        assert "3-element" in res["content"][0]["text"]

    @pytest.mark.parametrize(
        "kwargs",
        [
            # non-numeric elements: pre-fix these raise ValueError inside
            # np.array(dtype=float64), escaping the structured-error contract.
            {"force": ["a", "b", "c"]},
            {"torque": ["x", "y", "z"]},
            {"force": [0.0, 0.0, 0.0], "point": ["p", "q", "r"]},
            # nested lists are length-3 but not scalar numbers.
            {"force": [[1], [2], [3]]},
            # None element is neither a number nor iterable of length mismatch.
            {"force": [1, None, 3]},
        ],
    )
    def test_non_numeric_elements_error(self, sim_with_robot, kwargs):
        """Regression: 3-element vectors with non-numeric entries must return a
        structured error, not raise or silently coerce into the physics buffer."""
        res = sim_with_robot.apply_force(body_name="link1", **kwargs)
        assert res["status"] == "error"
        assert "must be numbers" in res["content"][0]["text"]

    @pytest.mark.parametrize(
        "kwargs",
        [
            # nan/inf pass the length check and, pre-fix, were silently latched
            # on the body - poisoning every subsequent mj_step.
            {"force": [float("nan"), 0.0, 0.0]},
            {"force": [float("inf"), 0.0, 0.0]},
            {"torque": [0.0, float("-inf"), 0.0]},
            {"force": [0.0, 0.0, 0.0], "point": [0.0, 0.0, float("nan")]},
        ],
    )
    def test_non_finite_elements_error(self, sim_with_robot, kwargs):
        """Regression: nan/inf in a force/torque/point vector must return a
        structured error instead of injecting non-finite state into physics."""
        res = sim_with_robot.apply_force(body_name="link1", **kwargs)
        assert res["status"] == "error"
        assert "finite numbers" in res["content"][0]["text"]


# negative/invalid mass, timestep


class TestMassAndTimestepValidation:
    def test_set_body_properties_negative_mass_errors(self, sim_with_robot):
        res = sim_with_robot.set_body_properties(body_name="link1", mass=-1.0)
        assert res["status"] == "error"
        assert "must be a finite number > 0" in res["content"][0]["text"]

    @pytest.mark.parametrize("bad", [float("nan"), float("inf"), -float("inf")])
    def test_set_body_properties_nonfinite_mass_errors(self, sim_with_robot, bad):
        """NaN/Inf must be rejected: ``nan <= 0`` and ``inf <= 0`` are both False,
        so a bare positivity guard would let them corrupt ``body_mass`` /
        ``body_inertia`` while reporting success."""
        res = sim_with_robot.set_body_properties(body_name="link1", mass=bad)
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_set_body_properties_zero_mass_errors(self, sim_with_robot):
        res = sim_with_robot.set_body_properties(body_name="link1", mass=0.0)
        assert res["status"] == "error"

    def test_set_body_properties_positive_mass_works(self, sim_with_robot):
        res = sim_with_robot.set_body_properties(body_name="link1", mass=2.5)
        assert res["status"] == "success"

    def test_set_timestep_negative_errors(self, sim_with_world):
        res = sim_with_world.set_timestep(-0.01)
        assert res["status"] == "error"
        assert "positive" in res["content"][0]["text"]

    def test_set_timestep_zero_errors(self, sim_with_world):
        res = sim_with_world.set_timestep(0)
        assert res["status"] == "error"

    def test_set_timestep_positive_works(self, sim_with_world):
        res = sim_with_world.set_timestep(0.001)
        assert res["status"] == "success"

    def test_set_timestep_large_warns_but_succeeds(self, sim_with_world):
        res = sim_with_world.set_timestep(0.5)
        assert res["status"] == "success"
        assert "Warning:" in res["content"][0]["text"] and "unusually" in res["content"][0]["text"]

    def test_set_timestep_nan_errors(self, sim_with_world):
        """NaN must not pass the positivity guard (nan <= 0 is False)."""

        res = sim_with_world.set_timestep(float("nan"))
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_set_timestep_inf_errors(self, sim_with_world):
        res = sim_with_world.set_timestep(float("inf"))
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_set_gravity_nan_errors(self, sim_with_world):
        """NaN in gravity components must be rejected."""
        res = sim_with_world.set_gravity([0, 0, float("nan")])
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_set_gravity_inf_errors(self, sim_with_world):
        res = sim_with_world.set_gravity([0, float("inf"), -9.81])
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_set_gravity_scalar_nan_errors(self, sim_with_world):
        """Scalar NaN must be rejected even though it goes through the
        `[0, 0, scalar]` expansion path before the finite check.

        Regression guard: prevents someone from accidentally moving the
        isfinite loop ahead of the scalar expansion and re-opening the gap.
        """
        res = sim_with_world.set_gravity(float("nan"))
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_set_gravity_scalar_inf_errors(self, sim_with_world):
        """Scalar ±Inf must also be rejected through the expansion path."""
        res = sim_with_world.set_gravity(float("inf"))
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]


# set_gravity dim validation


class TestSetGravityValidation:
    def test_two_element_gravity_errors(self, sim_with_world):
        res = sim_with_world.set_gravity([0.0, 0.0])
        assert res["status"] == "error"
        assert "3-element" in res["content"][0]["text"]

    def test_scalar_gravity_still_works(self, sim_with_world):
        # Scalar form convenience (z-only) preserved
        res = sim_with_world.set_gravity(-9.81)
        assert res["status"] == "success"

    def test_full_vector_gravity_works(self, sim_with_world):
        res = sim_with_world.set_gravity([1.0, 2.0, -9.0])
        assert res["status"] == "success"


# set_joint_positions list/dict support


class TestSetJointPositionsForms:
    def test_dict_form_works(self, sim_with_robot):
        # Pick a valid joint name from the robot
        joint_names = list(sim_with_robot._world.robots.values())[0].joint_names or []
        if not joint_names:
            import pytest as _pytest

            _pytest.skip("robot has no named joints")
        res = sim_with_robot.set_joint_positions(positions={joint_names[0]: 0.1})
        assert res["status"] == "success"

    def test_list_form_matches_count(self, sim_with_robot):
        joint_names = list(sim_with_robot._world.robots.values())[0].joint_names or []
        if not joint_names:
            import pytest as _pytest

            _pytest.skip("robot has no named joints")
        res = sim_with_robot.set_joint_positions(positions=[0.0] * len(joint_names))
        assert res["status"] == "success", res["content"][0]["text"]

    def test_list_form_wrong_length_errors(self, sim_with_robot):
        # 999 is almost certainly wrong for any robot
        res = sim_with_robot.set_joint_positions(positions=[0.1] * 999)
        assert res["status"] == "error"
        assert "does not match" in res["content"][0]["text"]


# set_joint_velocities list/dict support


class TestSetJointVelocitiesForms:
    """List/dict dispatch parity with set_joint_positions.

    set_joint_velocities accepts the same dict and positional-list forms as
    set_joint_positions (it documents "see set_joint_positions for list
    semantics"). These pin the list-form dispatch branches: count match, count
    mismatch, multi-robot ambiguity, an empty world, robot_name selection, the
    non-dict/list type guard, and unknown-joint skipping.
    """

    def test_dict_form_sets_qvel(self, sim_with_robot):
        joint_names = list(sim_with_robot._world.robots.values())[0].joint_names or []
        if not joint_names:
            pytest.skip("robot has no named joints")
        res = sim_with_robot.set_joint_velocities(velocities={joint_names[0]: 0.5})
        assert res["status"] == "success"
        assert "1/1" in res["content"][0]["text"]

    def test_list_form_matches_count(self, sim_with_robot):
        joint_names = list(sim_with_robot._world.robots.values())[0].joint_names or []
        if not joint_names:
            pytest.skip("robot has no named joints")
        res = sim_with_robot.set_joint_velocities(velocities=[0.1] * len(joint_names))
        assert res["status"] == "success", res["content"][0]["text"]
        assert f"{len(joint_names)}/{len(joint_names)}" in res["content"][0]["text"]

    def test_list_form_wrong_length_errors(self, sim_with_robot):
        res = sim_with_robot.set_joint_velocities(velocities=[0.1] * 999)
        assert res["status"] == "error"
        assert "does not match" in res["content"][0]["text"]

    def test_list_form_with_no_robot_errors(self, sim_with_world):
        """List form needs a robot to map positions onto joint names."""
        res = sim_with_world.set_joint_velocities(velocities=[0.1])
        assert res["status"] == "error"
        assert "requires a robot" in res["content"][0]["text"]

    def test_list_form_multi_robot_is_ambiguous(self, sim_with_world):
        """With >1 robot and no robot_name, the list form refuses to guess."""
        if sim_with_world.add_robot(name="panda", data_config="panda")["status"] != "success":
            pytest.skip("panda not available")
        if sim_with_world.add_robot(name="so100", data_config="so100", position=[1, 0, 0])["status"] != "success":
            pytest.skip("so100 not available")
        sim_with_world.reset()
        res = sim_with_world.set_joint_velocities(velocities=[0.1] * 9)
        assert res["status"] == "error"
        assert "ambiguous" in res["content"][0]["text"]

    def test_list_form_robot_name_selects_target(self, sim_with_world):
        """robot_name disambiguates which robot the list maps onto."""
        if sim_with_world.add_robot(name="panda", data_config="panda")["status"] != "success":
            pytest.skip("panda not available")
        if sim_with_world.add_robot(name="so100", data_config="so100", position=[1, 0, 0])["status"] != "success":
            pytest.skip("so100 not available")
        sim_with_world.reset()
        panda_joints = sim_with_world._world.robots["panda"].joint_names
        res = sim_with_world.set_joint_velocities(velocities=[0.1] * len(panda_joints), robot_name="panda")
        assert res["status"] == "success", res["content"][0]["text"]

    def test_list_form_unknown_robot_name_errors(self, sim_with_robot):
        res = sim_with_robot.set_joint_velocities(velocities=[0.1], robot_name="ghost")
        assert res["status"] == "error"
        assert "not found" in res["content"][0]["text"]

    def test_non_dict_or_list_type_rejected(self, sim_with_robot):
        res = sim_with_robot.set_joint_velocities(velocities=5)
        assert res["status"] == "error"
        assert "must be a dict or list" in res["content"][0]["text"]

    def test_dict_form_unknown_joint_is_rejected(self, sim_with_robot):
        """An unknown joint name errors instead of reporting a 0-of-1 success.

        The previous contract answered ``success`` with "Set 0/1 joint
        velocities (ignored: ['nope'])", so a caller branching on ``status``
        could not tell that nothing had been written.
        """
        res = sim_with_robot.set_joint_velocities(velocities={"nope": 1.0})
        assert res["status"] == "error"
        assert "nope" in res["content"][0]["text"]


# set_joint_positions / set_joint_velocities finite-value validation


class TestSetJointFiniteValidation:
    """Regression: joint setters must reject non-finite / non-numeric values.

    ``set_joint_positions`` / ``set_joint_velocities`` wrote each value straight
    into ``data.qpos`` / ``data.qvel`` via ``float(value)`` with no guard. A
    ``nan`` / ``inf`` landed directly in the state buffer -- ``mj_forward`` then
    propagates the ``nan`` across the whole kinematic tree (or an ``inf``
    velocity blows up the integrator on the next step) while the tool still
    returned ``status="success"``; a non-numeric value raised ``ValueError``
    past the structured-error dispatch contract. Both must now return a
    structured error and leave the state untouched.
    """

    def _first_joint(self, sim):
        joint_names = list(sim._world.robots.values())[0].joint_names or []
        if not joint_names:
            pytest.skip("robot has no named joints")
        return joint_names[0]

    @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
    def test_positions_nonfinite_rejected_and_qpos_untouched(self, sim_with_robot, bad):
        jn = self._first_joint(sim_with_robot)
        before = sim_with_robot._world._data.qpos.copy()
        res = sim_with_robot.set_joint_positions(positions={jn: bad})
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]
        # State must be left untouched (no nan/inf leaked into qpos).
        assert np.array_equal(sim_with_robot._world._data.qpos, before)
        assert np.all(np.isfinite(sim_with_robot._world._data.qpos))

    def test_positions_non_numeric_returns_structured_error(self, sim_with_robot):
        """A non-numeric value must NOT raise past dispatch."""
        jn = self._first_joint(sim_with_robot)
        res = sim_with_robot.set_joint_positions(positions={jn: "not-a-number"})
        assert res["status"] == "error"
        assert "must be a number" in res["content"][0]["text"]

    def test_positions_nonfinite_in_list_form_rejected(self, sim_with_robot):
        joint_names = list(sim_with_robot._world.robots.values())[0].joint_names or []
        if not joint_names:
            pytest.skip("robot has no named joints")
        vals = [0.0] * len(joint_names)
        vals[0] = float("nan")
        before = sim_with_robot._world._data.qpos.copy()
        res = sim_with_robot.set_joint_positions(positions=vals)
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]
        assert np.array_equal(sim_with_robot._world._data.qpos, before)

    @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
    def test_velocities_nonfinite_rejected_and_qvel_untouched(self, sim_with_robot, bad):
        jn = self._first_joint(sim_with_robot)
        before = sim_with_robot._world._data.qvel.copy()
        res = sim_with_robot.set_joint_velocities(velocities={jn: bad})
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]
        assert np.array_equal(sim_with_robot._world._data.qvel, before)
        assert np.all(np.isfinite(sim_with_robot._world._data.qvel))

    def test_velocities_non_numeric_returns_structured_error(self, sim_with_robot):
        jn = self._first_joint(sim_with_robot)
        res = sim_with_robot.set_joint_velocities(velocities={jn: [1, 2, 3]})
        assert res["status"] == "error"
        assert "must be a number" in res["content"][0]["text"]

    def test_bad_value_rejected_even_for_unknown_joint(self, sim_with_robot):
        """Input is validated up front, so a bad value fails fast regardless of
        whether the joint resolves -- an unknown joint no longer masks nan/inf."""
        res = sim_with_robot.set_joint_positions(positions={"ghost": float("inf")})
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_valid_numpy_scalar_positions_still_apply(self, sim_with_robot):
        """A finite NumPy scalar is a valid value and must still be written."""
        jn = self._first_joint(sim_with_robot)
        res = sim_with_robot.set_joint_positions(positions={jn: np.float64(0.2)})
        assert res["status"] == "success"
        assert "1/1" in res["content"][0]["text"]


# Policy-running guards


class TestPolicyRunningGuards:
    """Simulate policy-running state by poisoning _policy_threads.

    We insert a fake Future whose done() returns False so _require_no_running_policy
    flags a running policy without actually starting one.
    """

    def _install_fake_running_policy(self, sim):
        class _FakeRunningFuture:
            def done(self):
                return False

        sim._policy_threads["fake"] = _FakeRunningFuture()

    def test_reset_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.reset()
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]

    def test_set_gravity_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.set_gravity([0, 0, -5])
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]

    def test_set_timestep_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.set_timestep(0.001)
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]

    def test_set_joint_positions_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.set_joint_positions(positions={"nope": 0.0})
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]

    def test_apply_force_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.apply_force(body_name="link1", force=[1, 0, 0])
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]

    def test_set_body_properties_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.set_body_properties(body_name="link1", mass=3.0)
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]

    def test_randomize_blocked(self, sim_with_robot):
        self._install_fake_running_policy(sim_with_robot)
        res = sim_with_robot.randomize(seed=42)
        assert res["status"] == "error"
        assert "while a policy is running" in res["content"][0]["text"]


# add_robot initial state is zero


class TestAddRobotInitialState:
    """After add_robot, qpos/qvel/ctrl must be zero without needing reset()."""

    def test_initial_qpos_is_zero(self):
        import numpy as np

        sim = Simulation()
        try:
            sim.create_world()
            res = sim.add_robot(name="panda", data_config="panda")
            if res["status"] != "success":
                import pytest as _pytest

                _pytest.skip(f"panda not available: {res['content'][0]['text']}")
            # IMPORTANT: do NOT call reset. T6 requires that add_robot itself leaves a clean state.
            data = sim._world._data
            assert np.allclose(data.qpos, 0.0), f"qpos should be zero after add_robot, got {data.qpos}"
            assert np.allclose(data.qvel, 0.0), f"qvel should be zero after add_robot, got {data.qvel}"
            assert np.allclose(data.ctrl, 0.0), f"ctrl should be zero after add_robot, got {data.ctrl}"
        finally:
            sim.destroy()


# render camera strict validation


@requires_gl
class TestRenderCameraValidation:
    def test_unknown_camera_errors(self, sim_with_world):
        res = sim_with_world.render(camera_name="does_not_exist", width=64, height=48)
        assert res["status"] == "error"
        assert "not found" in res["content"][0]["text"]

    def test_default_camera_labelled_honestly(self, sim_with_world):
        res = sim_with_world.render(camera_name="default", width=64, height=48)
        if res["status"] != "success":
            import pytest as _pytest

            _pytest.skip(f"offscreen render unavailable: {res['content'][0]['text']}")
        assert "free (default)" in res["content"][0]["text"]

    def test_free_alias_labelled_honestly(self, sim_with_world):
        res = sim_with_world.render(camera_name="free", width=64, height=48)
        if res["status"] != "success":
            import pytest as _pytest

            _pytest.skip(f"offscreen render unavailable: {res['content'][0]['text']}")
        assert "free (default)" in res["content"][0]["text"]

    def test_render_depth_unknown_camera_errors(self, sim_with_world):
        res = sim_with_world.render_depth(camera_name="ghost_cam", width=64, height=48)
        assert res["status"] == "error"
        assert "not found" in res["content"][0]["text"]


# camera target actually applied


class TestAddCameraTargetOrients:
    """The 'headline broken feature': add_camera(target=...) was silently dropped
    so every custom camera rendered the same default view. These tests verify
    that orientation now flows through to the rendered pixels.
    """

    def _with_obj(self):
        """Create a world with a distinguishable colored object for the cameras to frame."""
        sim = Simulation()
        sim.create_world()
        # Add a vivid red box at origin to make camera differences visible.
        sim.add_object(
            name="target_box",
            shape="box",
            size=[0.3, 0.3, 0.3],
            position=[0.0, 0.0, 0.25],
            color=[1.0, 0.0, 0.0, 1.0],
            is_static=True,
        )
        return sim

    def test_degenerate_target_equals_position_errors(self):
        sim = self._with_obj()
        try:
            res = sim.add_camera(name="bad_cam", position=[1, 2, 3], target=[1, 2, 3])
            assert res["status"] == "error"
            assert "identical" in res["content"][0]["text"]
        finally:
            sim.destroy()

    def test_wrong_length_position_errors(self):
        sim = self._with_obj()
        try:
            res = sim.add_camera(name="bad_cam", position=[1, 2], target=[0, 0, 0])
            assert res["status"] == "error"
            assert "3-element" in res["content"][0]["text"]
        finally:
            sim.destroy()

    def test_camera_orientation_written(self):
        """A target'd camera must end up with a non-default orientation in the
        compiled model. Previously this test asserted on the raw ``xyaxes="..."``
        attribute in the scene XML, which the MjSpec builder path replaces with
        a ``quat`` attribute. Both representations resolve to the same rotation
        matrix in the compiled MjModel (``cam_mat0``) - which is what we
        actually care about for rendering.
        """
        import numpy as np

        sim = self._with_obj()
        try:
            res = sim.add_camera(name="side_cam", position=[2.0, 0.0, 0.3], target=[0.0, 0.0, 0.25])
            assert res["status"] == "success", res["content"][0]["text"]

            mj = sim._mj
            model = sim._world._model
            assert model is not None
            cam_id = mj.mj_name2id(model, mj.mjtObj.mjOBJ_CAMERA, "side_cam")
            assert cam_id >= 0, "camera was not registered in compiled model"

            # MuJoCo's default camera orientation is identity (looks along -Z).
            # Our target->quat conversion for position [2, 0, 0.3] looking at
            # [0, 0, 0.25] must produce a non-identity rotation.
            rot = model.cam_mat0[cam_id].reshape(3, 3)
            assert not np.allclose(rot, np.eye(3)), "camera has default (identity) orientation - target was ignored"
        finally:
            sim.destroy()

    def test_different_targets_produce_different_orientations(self):
        """Two cameras at the SAME position but different targets must produce
        DIFFERENT rotation matrices in the compiled MjModel. Before the
        camera-target fix (T* in PR #85) both cameras shared MuJoCo's default
        look direction, so rendered frames were identical regardless of the
        ``target`` argument.

        We assert on ``cam_mat0`` (the rotation matrix of the camera frame
        at qpos0) rather than rendered pixels, because offscreen GL on some
        CI runners produces blank frames and makes pixel comparison
        unreliable. cam_mat0 is representation-agnostic - works under both
        legacy MJCFBuilder (xyaxes-based) and SpecBuilder (quat-based) paths.
        """
        import numpy as np

        sim = self._with_obj()
        try:
            res_a = sim.add_camera(name="cam_a", position=[2.0, 0.0, 0.5], target=[0.0, 0.0, 0.25])
            res_b = sim.add_camera(name="cam_b", position=[2.0, 0.0, 0.5], target=[0.0, 2.0, 0.25])
            assert res_a["status"] == "success"
            assert res_b["status"] == "success"

            mj = sim._mj
            model = sim._world._model
            a_id = mj.mj_name2id(model, mj.mjtObj.mjOBJ_CAMERA, "cam_a")
            b_id = mj.mj_name2id(model, mj.mjtObj.mjOBJ_CAMERA, "cam_b")
            assert a_id >= 0 and b_id >= 0

            rot_a = model.cam_mat0[a_id].reshape(3, 3)
            rot_b = model.cam_mat0[b_id].reshape(3, 3)
            assert not np.allclose(rot_a, rot_b, atol=1e-3), (
                "cameras with different targets must have different orientations "
                "(their cam_mat0 rotation matrices are currently identical, which means "
                "`target` is being ignored)."
            )
        finally:
            sim.destroy()


class TestAddCameraParamValidation:
    """add_camera validates fov / width / height eagerly.

    Previously add_camera validated position, target, duplicate names and the
    parent_body mount, but accepted any fov and any render resolution. A
    non-positive or out-of-range fov silently registered a degenerate camera
    (fov <= 0) or aborted with a cryptic "spec recompile refused" (fov >= 180),
    and a non-positive / oversized width or height was accepted at config time
    only to fail with an opaque GL/Renderer error on the first render. These
    guard the config-time rejection with an actionable message.
    """

    def test_fov_zero_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=0.0)
        assert res["status"] == "error"
        assert "fov" in res["content"][0]["text"]
        assert "c" not in sim_with_world._world.cameras

    def test_fov_negative_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=-10.0)
        assert res["status"] == "error"
        assert "(0, 180)" in res["content"][0]["text"]

    def test_fov_at_180_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=180.0)
        assert res["status"] == "error"
        assert "fov" in res["content"][0]["text"]

    def test_fov_above_180_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=250.0)
        assert res["status"] == "error"
        # must fail cleanly, NOT with the opaque spec-recompile message
        assert "spec recompile refused" not in res["content"][0]["text"]

    def test_fov_nan_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=float("nan"))
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_fov_inf_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=float("inf"))
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_fov_bool_rejected(self, sim_with_world):
        # bool is an int subclass; True (== 1 deg) is almost certainly a caller bug.
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=True)
        assert res["status"] == "error"
        assert "finite number" in res["content"][0]["text"]

    def test_fov_numpy_float32_accepted(self, sim_with_world):
        # A NumPy scalar fov (e.g. np.float32 from a config array) is a finite
        # real number; it must be accepted, not rejected as "not a finite number".
        res = sim_with_world.add_camera(name="npf", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=np.float32(58.0))
        assert res["status"] == "success", res["content"][0]["text"]
        assert "npf" in sim_with_world._world.cameras

    def test_fov_numpy_int64_accepted(self, sim_with_world):
        res = sim_with_world.add_camera(name="npi", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=np.int64(58))
        assert res["status"] == "success", res["content"][0]["text"]
        assert "npi" in sim_with_world._world.cameras

    def test_fov_numpy_bool_still_rejected(self, sim_with_world):
        # np.bool_ is not numbers.Real; treat it like Python bool - a caller bug.
        res = sim_with_world.add_camera(name="npb", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=np.bool_(True))
        assert res["status"] == "error"
        assert "finite number" in res["content"][0]["text"]
        assert "npb" not in sim_with_world._world.cameras

    def test_fov_numpy_nan_still_rejected(self, sim_with_world):
        res = sim_with_world.add_camera(
            name="npn", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=np.float32("nan")
        )
        assert res["status"] == "error"
        assert "finite" in res["content"][0]["text"]

    def test_width_zero_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], width=0)
        assert res["status"] == "error"
        assert "> 0" in res["content"][0]["text"]
        assert "c" not in sim_with_world._world.cameras

    def test_height_negative_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], height=-5)
        assert res["status"] == "error"
        assert "> 0" in res["content"][0]["text"]

    def test_width_above_abs_max_errors(self, sim_with_world):
        res = sim_with_world.add_camera(name="c", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], width=100000)
        assert res["status"] == "error"
        assert "add_camera" in res["content"][0]["text"]
        assert "render:" not in res["content"][0]["text"]

    def test_valid_camera_still_added(self, sim_with_world):
        res = sim_with_world.add_camera(
            name="good", position=[0.5, 0, 0.3], target=[0.2, 0, 0.05], fov=58.0, width=256, height=256
        )
        assert res["status"] == "success"
        assert "good" in sim_with_world._world.cameras


class TestAddCameraPositionTargetValidation:
    """add_camera validates position/target elements are finite real numbers.

    Pre-fix, add_camera only checked that position/target were 3-element
    vectors, not that each element was a finite number. A non-numeric element
    (e.g. ["a", "b", "c"]) raised TypeError inside the degenerate-look
    comparison -- escaping the structured-error tool contract -- and a nan/inf
    component passed the length check and slipped silently into the camera's
    xyaxes look-direction basis (a 0/0 division), registering a broken camera
    that renders nothing. These pin the per-element guard, mirroring
    apply_force, and confirm NumPy scalar components are still accepted.
    """

    @pytest.mark.parametrize(
        "kwargs",
        [
            {"position": ["a", "b", "c"], "target": [0.0, 0.0, 1.0]},
            {"position": [0.5, 0.0, 0.3], "target": ["x", "y", "z"]},
            # nested lists are length-3 but not scalar numbers.
            {"position": [[1], 2, 3], "target": [0.0, 0.0, 1.0]},
            # None is neither a number nor coercible.
            {"position": [1.0, None, 3.0], "target": [0.0, 0.0, 1.0]},
        ],
    )
    def test_non_numeric_elements_error(self, sim_with_world, kwargs):
        res = sim_with_world.add_camera(name="bad", **kwargs)
        assert res["status"] == "error"
        assert "must be numbers" in res["content"][0]["text"]
        assert "bad" not in sim_with_world._world.cameras

    @pytest.mark.parametrize(
        "kwargs",
        [
            {"position": [float("nan"), 0.0, 0.3], "target": [0.0, 0.0, 1.0]},
            {"position": [float("inf"), 0.0, 0.3], "target": [0.0, 0.0, 1.0]},
            {"position": [0.5, 0.0, 0.3], "target": [float("-inf"), 0.0, 0.0]},
            {"position": [0.5, 0.0, 0.3], "target": [0.0, float("nan"), 0.0]},
        ],
    )
    def test_non_finite_elements_error(self, sim_with_world, kwargs):
        res = sim_with_world.add_camera(name="bad", **kwargs)
        assert res["status"] == "error"
        assert "finite numbers" in res["content"][0]["text"]
        assert "bad" not in sim_with_world._world.cameras

    def test_numpy_scalar_components_accepted(self, sim_with_world):
        # Components read from a config array arrive as NumPy scalars; they are
        # finite real numbers and must be accepted like plain floats.
        res = sim_with_world.add_camera(
            name="npc",
            position=[np.float32(0.5), np.float64(0.0), np.int64(1)],
            target=[0.0, 0.0, 0.0],
        )
        assert res["status"] == "success", res["content"][0]["text"]
        assert "npc" in sim_with_world._world.cameras


# send_action ordered-vector normalization


class TestSendActionVectorValidation:
    """Pin send_action's ordered-vector contract.

    send_action accepts either a ``{name: value}`` mapping or an ordered numeric
    vector aligned with ``robot_action_keys``. A vector with a non-numeric entry
    (e.g. a stray string) must fail with a structured error naming the offending
    conversion, not raise past the tool boundary or coerce garbage downstream.
    """

    def test_non_numeric_entry_returns_structured_error(self, sim_with_robot):
        keys = sim_with_robot.robot_action_keys("panda")
        assert len(keys) >= 2, keys
        bad_vector = [0.0, "not_a_number", *([0.0] * (len(keys) - 2))]
        res = sim_with_robot.send_action(bad_vector)
        assert res["status"] == "error", res
        assert "non-numeric entry" in res["content"][0]["text"]

    def test_numeric_vector_still_applies(self, sim_with_robot):
        keys = sim_with_robot.robot_action_keys("panda")
        res = sim_with_robot.send_action([0.0] * len(keys))
        assert res["status"] == "success", res["content"][0]["text"]


# Non-sized scalar rejection: len() on a scalar raises TypeError, which the
# facade must translate into a structured error instead of letting it escape
# past the tool boundary.


class TestApplyForceNonSizedVector:
    """apply_force must reject a non-sized scalar force/torque/point.

    A scalar (e.g. ``force=5``) is not a 3-vector; ``len()`` raises TypeError.
    Without the guard that error would escape the tool contract; with it the
    caller gets an actionable "must be a list/tuple of 3 numbers" message and
    the physics buffer is never touched.
    """

    @pytest.mark.parametrize("field", ["force", "torque"])
    def test_scalar_force_or_torque_errors(self, sim_with_robot, field):
        res = sim_with_robot.apply_force(body_name="link1", **{field: 5})
        assert res["status"] == "error", res
        assert f"'{field}' must be a list/tuple of 3 numbers" in res["content"][0]["text"]

    def test_scalar_point_errors(self, sim_with_robot):
        # point alone would trip the "specify force or torque" guard first, so
        # pair it with a valid force to reach the point-vector validation.
        res = sim_with_robot.apply_force(body_name="link1", force=[1.0, 0.0, 0.0], point=9)
        assert res["status"] == "error", res
        assert "'point' must be a list/tuple of 3 numbers" in res["content"][0]["text"]


class TestRaycastNonSizedVector:
    """raycast must reject a non-sized scalar origin/direction.

    mj_ray aborts the process on malformed input, so the facade validates
    shapes first. A scalar origin/direction makes ``len()`` raise TypeError,
    which must degrade to a structured error, not a crash.
    """

    def test_scalar_origin_errors(self, sim_with_robot):
        res = sim_with_robot.raycast(origin=5, direction=[0, 0, -1])
        assert res["status"] == "error", res
        assert "must be lists of 3 numbers" in res["content"][0]["text"]

    def test_scalar_direction_errors(self, sim_with_robot):
        res = sim_with_robot.raycast(origin=[0, 0, 5], direction=7)
        assert res["status"] == "error", res
        assert "must be lists of 3 numbers" in res["content"][0]["text"]


class TestSetBodyPropertiesNonNumericMass:
    """set_body_properties must reject a non-numeric mass.

    ``float("heavy")`` raises ValueError; the facade catches it and returns an
    actionable error rather than letting it escape the tool boundary.
    """

    def test_non_numeric_mass_errors(self, sim_with_robot):
        res = sim_with_robot.set_body_properties(body_name="link1", mass="heavy")
        assert res["status"] == "error", res
        assert "'mass' must be a positive number" in res["content"][0]["text"]


# A scene carrying named + unnamed sensors, so get_sensor_data exercises both
# the unnamed-sensor fallback naming and the "sensor not found" contract.
_SENSOR_SCENE_MJCF = """<mujoco>
  <worldbody>
    <body name="pend">
      <joint name="hinge" type="hinge" axis="0 1 0"/>
      <geom type="capsule" size="0.02 0.1" fromto="0 0 0 0 0 -0.2"/>
    </body>
  </worldbody>
  <sensor>
    <jointpos name="hinge_pos" joint="hinge"/>
    <jointvel joint="hinge"/>
  </sensor>
</mujoco>"""


@requires_gl
class TestGetSensorDataContract:
    """get_sensor_data reporting over a model that actually has sensors.

    Covers the read path that the empty-world "no sensors" tests cannot reach:
    an unnamed sensor gets a synthesized ``sensor_<i>`` name, and requesting a
    name that is not present returns a structured "not found" error even though
    the model does carry sensors.
    """

    @pytest.fixture
    def sim_with_sensors(self):
        sim = Simulation()
        sim.create_world()
        res = sim.replace_scene_mjcf(_SENSOR_SCENE_MJCF)
        assert res["status"] == "success", res["content"][0]["text"]
        yield sim
        sim.destroy()

    def test_lists_named_and_synthesized_sensor_names(self, sim_with_sensors):
        res = sim_with_sensors.get_sensor_data()
        assert res["status"] == "success", res
        text = res["content"][0]["text"]
        # named sensor kept verbatim; the anonymous jointvel gets a fallback name
        assert "hinge_pos" in text
        assert "sensor_1" in text

    def test_named_sensor_scopes_to_single_reading(self, sim_with_sensors):
        res = sim_with_sensors.get_sensor_data(sensor_name="hinge_pos")
        assert res["status"] == "success", res
        assert "hinge_pos" in res["content"][0]["text"]

    def test_unknown_sensor_errors_when_model_has_sensors(self, sim_with_sensors):
        res = sim_with_sensors.get_sensor_data(sensor_name="nope")
        assert res["status"] == "error", res
        assert "Sensor 'nope' not found." in res["content"][0]["text"]


class TestRouterVectorNumpyScalars:
    """Router-level vector params accept NumPy scalar components.

    The agent-tool dispatch router (``sim(action=..., **kwargs)`` ->
    ``_validate_and_build_kwargs``) length- and dtype-checks every vector
    parameter (position, force, torque, gravity, direction, point,
    orientation, color) before the value reaches NumPy / MuJoCo. It used
    ``isinstance(component, (int, float))``, which is ``False`` for NumPy
    scalars (only ``np.float64`` subclasses ``float``). Vector params
    routinely arrive from an observation or ``mj_data`` -- a NumPy array whose
    elements are ``np.float32`` / ``np.int64`` -- so a natural
    ``position=[obs[0], obs[1], obs[2]]`` was rejected as "must be numeric,
    got float32" even though every component is a finite real number. The
    guard now uses ``numbers.Real`` so NumPy scalars pass while ``bool`` /
    ``np.bool_`` / non-numeric junk stay rejected.
    """

    @pytest.fixture
    def sim_with_world(self):
        sim = Simulation()
        sim.create_world()
        yield sim
        sim.destroy()

    def test_numpy_scalar_position_accepted(self, sim_with_world):
        """A position built from NumPy scalars (as from an observation) is
        accepted by the router instead of erroring on dtype."""
        np = pytest.importorskip("numpy")
        res = sim_with_world(
            action="add_object",
            shape="box",
            size=[0.02, 0.02, 0.02],
            position=[np.float32(0.3), np.int64(0), np.float64(0.5)],
            name="np_cube",
        )
        assert res["status"] == "success", res

    def test_python_bool_component_still_rejected(self, sim_with_world):
        """``bool`` is an ``int`` subclass but is not a valid coordinate; it
        must stay rejected even though the guard now accepts ``numbers.Real``."""
        res = sim_with_world(
            action="add_object",
            shape="box",
            size=[0.02, 0.02, 0.02],
            position=[True, 0.0, 0.5],
            name="bool_cube",
        )
        assert res["status"] == "error", res
        assert "must be numeric" in res["content"][0]["text"]

    def test_numpy_bool_component_still_rejected(self, sim_with_world):
        """``np.bool_`` is not a ``numbers.Real`` and stays rejected."""
        np = pytest.importorskip("numpy")
        res = sim_with_world(
            action="add_object",
            shape="box",
            size=[0.02, 0.02, 0.02],
            position=[np.bool_(True), 0.0, 0.5],
            name="npbool_cube",
        )
        assert res["status"] == "error", res
        assert "must be numeric" in res["content"][0]["text"]

    def test_non_numeric_component_still_rejected(self, sim_with_world):
        """Non-numeric junk still returns the structured dtype error."""
        res = sim_with_world(
            action="add_object",
            shape="box",
            size=[0.02, 0.02, 0.02],
            position=["a", "b", "c"],
            name="str_cube",
        )
        assert res["status"] == "error", res
        assert "must be numeric" in res["content"][0]["text"]
