diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index cfd575a9..87d93ed4 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3818,13 +3818,23 @@ class Flomni( if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): print("... excellent!") else: - self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) - self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) - self.fermat_asymmetry = self._get_val( - "", self.fermat_asymmetry, float + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + + self.tomo_countingtime = self._get_val( + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) - self.fovx = self._get_val(" um", self.fovx, float) - self.fovy = self._get_val(" um", self.fovy, float) + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.001, max_=1000.0 + ) + self.fermat_asymmetry = self._get_val( + "", + self.fermat_asymmetry, + float, + min_=FlomniFermatScan.MIN_FERMAT_ASYMMETRY, + max_=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, + ) + self.fovx = self._get_val(" um", self.fovx, float, min_=0.1, max_=220.0) + self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=100.0) if self.single_point_instead_of_fermat_scan: print( "Stitching is disabled while single point instead of fermat scan is" @@ -3833,14 +3843,16 @@ class Flomni( self.stitch_x = 0 self.stitch_y = 0 else: - self.stitch_x = self._get_val("", self.stitch_x, int) - self.stitch_y = self._get_val("", self.stitch_y, int) + self.stitch_x = self._get_val("", self.stitch_x, int, min_=0, max_=50) + self.stitch_y = self._get_val("", self.stitch_y, int, min_=0, max_=50) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue ", self.ptycho_reconstruct_foldername, str ) - self.manual_shift_y = self._get_val(" um", self.manual_shift_y, float) + self.manual_shift_y = self._get_val( + " um", self.manual_shift_y, float, min_=-50.0, max_=50.0 + ) self.frames_per_trigger = self._get_val( - "Frames per trigger (burst)", self.frames_per_trigger, int + "Frames per trigger (burst)", self.frames_per_trigger, int, min_=1, max_=100 ) self.single_point_instead_of_fermat_scan = bool( self._get_val( @@ -3863,6 +3875,8 @@ class Flomni( " um (0 = off)", self.single_point_random_shift_max, float, + min_=0.0, + max_=10.0, ) print("Tomography type:") @@ -3884,7 +3898,7 @@ class Flomni( # never reflect a prior adjustment. _, _, current_total = self._tomo_type1_actual_grid() tomo_numberofprojections = self._get_val( - "Total number of projections", current_total, int + "Total number of projections", current_total, int, min_=8, max_=10000 ) # N/step (and therefore total projections) are always # computed against a fixed 180 degrees, independent of @@ -3933,11 +3947,15 @@ class Flomni( "Number of projections sorted per bunch (default 20)", self.golden_ratio_bunch_size, int, + min_=1, + max_=10000, ) self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo 1/0 ?", @@ -3950,12 +3968,16 @@ class Flomni( "Number of projections per sub-tomogram", int(180 / self.tomo_angle_stepsize), int, + min_=1, + max_=10000, ) self.tomo_angle_stepsize = 180 / numprj self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo", @@ -3964,8 +3986,21 @@ class Flomni( ) @staticmethod - def _get_val(msg: str, default_value, data_type): - return data_type(input(f"{msg} ({default_value}): ") or default_value) + def _get_val(msg: str, default_value, data_type, min_=None, max_=None): + """Prompt for a value, casting to data_type. When min_/max_ are + given, re-prompts (rather than silently clamping) until the typed + value is in range, mirroring the Tomo Parameters GUI's spinbox + bounds (tomo_params.py) so tomo_parameters() enforces the same + limits instead of accepting anything of the right type.""" + while True: + val = data_type(input(f"{msg} ({default_value}): ") or default_value) + if min_ is not None and val < min_: + print(f"Value must be >= {min_}. Please try again.") + continue + if max_ is not None and val > max_: + print(f"Value must be <= {max_}. Please try again.") + continue + return val # Ordered set of all global-var-backed tomo scan parameters: exactly the # settings shown by tomo_parameters(), plus manual_shift_y/ diff --git a/tests/tests_bec_ipython_client/test_flomni_get_val_range.py b/tests/tests_bec_ipython_client/test_flomni_get_val_range.py new file mode 100644 index 00000000..f1b2704e --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_get_val_range.py @@ -0,0 +1,61 @@ +"""Tests for Flomni._get_val()'s min_/max_ range validation. + +tomo_parameters() (the CLI wizard) used to accept any value of the right +type with no range check at all, even though the Tomo Parameters GUI +enforces spinbox ranges for the same fields (tomo_params.py) -- this let the +CLI silently set e.g. manual_shift_y or fovx far outside their GUI-intended +bounds. _get_val() now optionally re-prompts until the typed value is in +range, so tomo_parameters() enforces the same limits as the GUI. +""" + +import builtins + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni + + +def _fake_inputs(monkeypatch, values): + it = iter(values) + monkeypatch.setattr(builtins, "input", lambda prompt="": next(it)) + + +def test_get_val_without_bounds_accepts_anything(monkeypatch): + _fake_inputs(monkeypatch, ["12345"]) + assert Flomni._get_val("x", 0.0, float) == 12345.0 + + +def test_get_val_accepts_in_range_value_first_try(monkeypatch): + _fake_inputs(monkeypatch, ["25"]) + assert Flomni._get_val("x", 0.0, float, min_=0.1, max_=220.0) == 25.0 + + +def test_get_val_reprompts_when_above_max(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["500", "50"]) + result = Flomni._get_val("FOV X", 0.0, float, min_=0.1, max_=220.0) + assert result == 50.0 + assert "must be <= 220.0" in capsys.readouterr().out + + +def test_get_val_reprompts_when_below_min(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["-999", "-30"]) + result = Flomni._get_val("manual_shift_y", 0.0, float, min_=-50.0, max_=50.0) + assert result == -30.0 + assert "must be >= -50.0" in capsys.readouterr().out + + +def test_get_val_reprompts_until_valid_across_multiple_bad_attempts(monkeypatch): + _fake_inputs(monkeypatch, ["-5", "999", "30"]) + assert Flomni._get_val("stitch X", 0, int, min_=0, max_=50) == 30 + + +def test_get_val_empty_input_uses_default_and_still_checked(monkeypatch): + # empty input -> falls back to default_value, which is already in range + _fake_inputs(monkeypatch, [""]) + assert Flomni._get_val("x", 25, int, min_=0, max_=50) == 25 + + +def test_get_val_boundary_values_are_accepted(monkeypatch): + _fake_inputs(monkeypatch, ["0.1", "220.0"]) + assert Flomni._get_val("a", 0.0, float, min_=0.1, max_=220.0) == 0.1 + assert Flomni._get_val("b", 0.0, float, min_=0.1, max_=220.0) == 220.0