1+ import math
12import subprocess
23import sys
34
@@ -455,60 +456,78 @@ def test_time_constant_iir_filter(self):
455456 assert initial_output < filtered_output < 1.0
456457
457458
458- class TestActuatorValidation :
459- """Test suite for actuator parameter validation."""
460-
461- def test_invalid_demand_rate_negative (self ):
462- """Test that negative demand rate is rejected."""
463- with pytest .raises (ValueError ):
464- RollActuator (demand_rate = - 1 )
459+ NAN = float ("nan" )
465460
466- def test_invalid_range (self ):
467- """Test that invalid range is rejected."""
468- with pytest .raises (ValueError ):
469- RollActuator (
470- max_roll_torque = - 5
471- ) # This creates range (5, -5) which is invalid
472461
473- def test_invalid_time_constant_negative (self ):
474- """Test that negative time constant is rejected."""
475- with pytest .raises (ValueError ):
476- ThrottleActuator (throttle_time_constant = - 0.1 )
462+ def _as_source (value ):
463+ """Render a value as source the ``-O`` subprocess can evaluate.
477464
478- def test_invalid_rate_limit_negative (self ):
479- """Test that negative rate limit is rejected."""
480- with pytest .raises (ValueError ):
481- ThrustVectorActuator (gimbal_rate_limit = - 1.0 )
465+ ``repr`` is almost enough, except that it renders NaN as the bare name
466+ ``nan``, which the subprocess does not have bound. Left as ``repr`` the NaN
467+ cases died on NameError, and a test that only checked the return code would
468+ have called that a pass.
469+ """
470+ if isinstance (value , float ) and math .isnan (value ):
471+ return 'float("nan")'
472+ if isinstance (value , tuple ):
473+ return "(" + ", " .join (_as_source (item ) for item in value ) + ",)"
474+ return repr (value )
475+
476+
477+ # One table, walked twice: once in-process for the message, once under ``-O``.
478+ # Keeping them in step is the point. An argument that is only rejected in the
479+ # default interpreter is not rejected, because the checks these replaced were
480+ # asserts and asserts are what ``-O`` removes.
481+ #
482+ # Each entry names the message it expects, so a case cannot pass on some other
483+ # argument's check. Every NaN case is here because NaN fails every ordered
484+ # comparison: `nan <= 0` is false just as `nan > 0` is, so a check written as the
485+ # inverted comparison accepts it while the assert it replaces rejected it.
486+ INVALID_ARGUMENTS = [
487+ (RollActuator , {"demand_rate" : - 1 }, "demand_rate" ),
488+ (RollActuator , {"demand_rate" : 0 }, "demand_rate" ),
489+ (RollActuator , {"demand_rate" : NAN }, "demand_rate" ),
490+ (RollActuator , {"max_roll_torque" : - 5 }, "actuator_range" ),
491+ (ThrottleActuator , {"throttle_range" : (NAN , 1.0 )}, "actuator_range" ),
492+ (ThrottleActuator , {"throttle_range" : (0.0 , NAN )}, "actuator_range" ),
493+ (ThrustVectorActuator , {"gimbal_rate_limit" : - 1.0 }, "rate_limit" ),
494+ (ThrustVectorActuator , {"gimbal_rate_limit" : NAN }, "rate_limit" ),
495+ (ThrottleActuator , {"throttle_time_constant" : - 0.1 }, "time_constant" ),
496+ (ThrottleActuator , {"throttle_time_constant" : NAN }, "time_constant" ),
497+ (ThrottleActuator , {"initial_throttle" : NAN }, "initial output" ),
498+ # clamp is what would otherwise absorb an out-of-range initial value, and
499+ # np.clip returns NaN for NaN, so both settings have to refuse it.
500+ (ThrottleActuator , {"initial_throttle" : NAN , "clamp" : False }, "initial output" ),
501+ ]
502+ INVALID_IDS = [
503+ f"{ cls .__name__ } -{ '-' .join (kwargs )} -{ 'clamped' if kwargs .get ('clamp' , True ) else 'unclamped' } "
504+ for cls , kwargs , _ in INVALID_ARGUMENTS
505+ ]
482506
483- def test_demand_rate_none_builds_a_continuous_actuator (self ):
484- """None is the documented continuous-time mode and must be accepted.
485507
486- The check used to read ``demand_rate > 0 or demand_rate is None``, and
487- Python evaluates the left operand first, so this raised TypeError and the
488- mode could not be constructed at all.
489- """
490- actuator = RollActuator (demand_rate = None )
508+ class TestActuatorValidation :
509+ """Test suite for actuator parameter validation."""
491510
492- assert actuator .demand_rate is None
511+ @pytest .mark .parametrize (
512+ "actuator_class, kwargs, message" , INVALID_ARGUMENTS , ids = INVALID_IDS
513+ )
514+ def test_invalid_arguments_are_rejected (self , actuator_class , kwargs , message ):
515+ with pytest .raises (ValueError , match = message ):
516+ actuator_class (** kwargs )
493517
494518 @pytest .mark .parametrize (
495- "actuator_class, kwargs" ,
496- [
497- (RollActuator , {"demand_rate" : - 1 }),
498- (RollActuator , {"max_roll_torque" : - 5 }),
499- (ThrottleActuator , {"throttle_time_constant" : - 0.1 }),
500- (ThrustVectorActuator , {"gimbal_rate_limit" : - 1.0 }),
501- ],
519+ "actuator_class, kwargs, message" , INVALID_ARGUMENTS , ids = INVALID_IDS
502520 )
503- def test_validation_survives_optimized_mode (self , actuator_class , kwargs ):
521+ def test_validation_survives_optimized_mode (self , actuator_class , kwargs , message ):
504522 """``python -O`` drops assert statements, so these must not be asserts.
505523
506524 Run in a subprocess because the flag is set at interpreter startup. Under
507525 the old bare asserts every one of these was accepted in silence.
508526 """
527+ arguments = ", " .join (f"{ k } ={ _as_source (v )} " for k , v in kwargs .items ())
509528 source = (
510529 "from rocketpy.rocket.actuator import "
511- f"{ actuator_class .__name__ } as A; A(** { kwargs !r } )"
530+ f"{ actuator_class .__name__ } as A; A({ arguments } )"
512531 )
513532 result = subprocess .run (
514533 [sys .executable , "-O" , "-c" , source ],
@@ -519,6 +538,41 @@ def test_validation_survives_optimized_mode(self, actuator_class, kwargs):
519538
520539 assert result .returncode != 0 , "invalid arguments were accepted under -O"
521540 assert "ValueError" in result .stderr
541+ assert message in result .stderr
542+
543+ def test_demand_rate_none_builds_a_continuous_actuator (self ):
544+ """None is the documented continuous-time mode and must be accepted.
545+
546+ The check used to read ``demand_rate > 0 or demand_rate is None``, and
547+ Python evaluates the left operand first, so this raised TypeError and the
548+ mode could not be constructed at all.
549+ """
550+ actuator = RollActuator (demand_rate = None )
551+
552+ assert actuator .demand_rate is None
553+
554+ @pytest .mark .parametrize (
555+ "actuator_class, kwargs" ,
556+ [
557+ (RollActuator , {"torque_rate_limit" : 0.0 }),
558+ (ThrottleActuator , {"throttle_time_constant" : 0.0 }),
559+ (ThrustVectorActuator , {"gimbal_rate_limit" : 0.0 }),
560+ ],
561+ )
562+ def test_zero_is_accepted_where_the_bound_is_non_negative (
563+ self , actuator_class , kwargs
564+ ):
565+ """Zero is on the legal side of every non-negative bound.
566+
567+ Worth its own case because the fix moved these from ``x < 0`` to
568+ ``not x >= 0``, and an off-by-one there would turn the documented "no
569+ dynamics" and "no rate limit" settings into errors. A zero rate limit
570+ does freeze the actuator, but that is the caller's business, and the
571+ constructor is not where that is decided.
572+ """
573+ actuator = actuator_class (** kwargs )
574+
575+ assert actuator is not None
522576
523577
524578class TestActuatorInitialOutput :
@@ -557,6 +611,26 @@ def test_without_clamping_it_warns_instead(self):
557611
558612 assert actuator .actuator_initial_output == 2.0
559613
614+ def test_the_warning_is_not_blamed_on_the_actuator_module (self ):
615+ """``pytest.warns`` reads the message, and the message is not the whole
616+ warning. Without a stacklevel the report points at the ``warnings.warn``
617+ line inside ``actuator.py``, which is the same line for every caller, so
618+ ``-W`` filters keyed on a module and the printed location are both
619+ useless.
620+
621+ Asserting the negative rather than a specific file because the warning is
622+ raised in a base ``__init__`` reached through ``super()``, so no single
623+ stacklevel lands on user code for every actuator: 2 reaches the concrete
624+ subclass, and the dual-axis actuator adds another frame on top of that.
625+ Not blaming the base module is the part that holds for all of them.
626+ """
627+ with pytest .warns (UserWarning , match = "outside its range" ) as record :
628+ ThrottleActuator (
629+ throttle_range = (0.0 , 1.0 ), initial_throttle = 2.0 , clamp = False
630+ )
631+
632+ assert not record [0 ].filename .endswith ("actuator.py" )
633+
560634
561635class TestActuatorWarnings :
562636 """Test suite for actuator warning conditions."""
0 commit comments