Skip to content

Commit d09ceca

Browse files
committed
test(param editor table): add more BDD tests
1 parent f9fed24 commit d09ceca

3 files changed

Lines changed: 904 additions & 456 deletions

File tree

ardupilot_methodic_configurator/frontend_tkinter_parameter_editor_table.py

Lines changed: 60 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,58 @@ def _create_value_different_label(self, param: ArduPilotParameter) -> ttk.Label:
327327
"""Create a label indicating if the new value is different from current FC value."""
328328
return ttk.Label(self.view_port, text=NEW_VALUE_DIFFERENT_STR if param.is_different_from_fc else " ")
329329

330+
def _handle_parameter_value_update( # pylint: disable=too-many-return-statements # noqa: PLR0911
331+
self, param: ArduPilotParameter, new_value: str, include_range_check: bool = True
332+
) -> bool:
333+
"""
334+
Handle parameter value updates with consistent error handling.
335+
336+
This method centralizes the error handling logic for parameter value updates,
337+
including user confirmation for out-of-range values.
338+
339+
Args:
340+
param: The ArduPilotParameter to update
341+
new_value: The new value to set (as string)
342+
include_range_check: Whether to perform range checking and ask user for confirmation
343+
344+
Returns:
345+
True if the value was successfully updated, False otherwise
346+
347+
"""
348+
try:
349+
# Attempt to set the new value; the model will validate it
350+
param.set_new_value(new_value)
351+
return True
352+
except ParameterUnchangedError:
353+
# Valid but no change — just refresh the UI and do not mark edited
354+
return False
355+
except ParameterOutOfRangeError as oor:
356+
# User-visible warning from model about out-of-range value
357+
if not include_range_check:
358+
# Caller doesn't want range checking, treat as error
359+
logging_exception(_("Parameter %s out of range: %s"), param.name, oor)
360+
messagebox.showerror(_("Out-of-range value"), str(oor))
361+
return False
362+
363+
# Ask the user if they want to accept the out-of-range value
364+
msg = str(oor) + _(" Use out-of-range value?")
365+
if messagebox.askyesno(_("Out-of-range value"), msg, icon="warning"):
366+
# Retry accepting the value while telling the model to ignore range checks
367+
try:
368+
param.set_new_value(new_value, ignore_out_of_range=True)
369+
return True
370+
except (ValueError, TypeError) as exc:
371+
# Even with ignore_out_of_range, the value might be invalid
372+
logging_exception(_("Could not set parameter %s to %s: %s"), param.name, new_value, exc)
373+
messagebox.showerror(_("Invalid value"), str(exc))
374+
return False
375+
return False
376+
except (ValueError, TypeError) as exc:
377+
# Invalid input according to model
378+
logging_exception(_("Invalid value for %s: %s"), param.name, exc)
379+
messagebox.showerror(_("Invalid value"), str(exc))
380+
return False
381+
330382
def _update_combobox_style_on_selection( # pylint: disable=too-many-arguments, too-many-positional-arguments
331383
self,
332384
combobox_widget: PairTupleCombobox,
@@ -337,17 +389,9 @@ def _update_combobox_style_on_selection( # pylint: disable=too-many-arguments,
337389
) -> None:
338390
"""Update the combobox style based on selection."""
339391
new_value_str = combobox_widget.get_selected_key() or ""
340-
try:
341-
# Pass the string to the domain model; it will validate and raise on error
342-
param.set_new_value(new_value_str)
343-
except ParameterUnchangedError:
344-
# valid but no change; just refresh style
345-
pass
346-
except (ValueError, TypeError) as exc: # user provided invalid input
347-
msg = _("Could not apply the selected value: {new_value_str}").format(new_value_str=new_value_str)
348-
logging_exception(msg, exc)
349-
messagebox.showerror(_("Error"), str(exc))
350-
else:
392+
393+
# Use centralized error handling for parameter value updates
394+
if self._handle_parameter_value_update(param, new_value_str, include_range_check=False):
351395
# Success: mark edited and sync the ArduPilotParameter back to filesystem
352396
show_tooltip(change_reason_widget, param.tooltip_change_reason)
353397
value_is_different.config(text=NEW_VALUE_DIFFERENT_STR if param.is_different_from_fc else " ")
@@ -465,26 +509,8 @@ def _on_parameter_value_change(event: tk.Event) -> None:
465509
if hasattr(event, "type") and event.type == tk.EventType.KeyPress: # KeyPress event (Return/Enter)
466510
self._last_return_values[event.widget] = new_value
467511

468-
valid = True
469-
470-
try:
471-
# first attempt: let the model validate the provided string
472-
# (it will convert/validate as required)
473-
param.set_new_value(new_value)
474-
except ParameterOutOfRangeError as oor: # user-visible warning from model
475-
# Ask the user if they want to accept the out-of-range value
476-
if messagebox.askyesno(_("Out-of-range value"), str(oor) + _(" Use out-of-range value?"), icon="warning"):
477-
# Retry accepting the value while telling the model to ignore range checks
478-
param.set_new_value(new_value, ignore_out_of_range=True)
479-
else:
480-
valid = False
481-
except ParameterUnchangedError:
482-
# Valid but no change — just refresh the UI and do not mark edited
483-
valid = False
484-
except (ValueError, TypeError) as exc: # invalid input according to model
485-
logging_exception(_("Invalid value for %s: %s"), param.name, exc)
486-
messagebox.showerror(_("Invalid value"), str(exc))
487-
valid = False
512+
# Use centralized error handling for parameter value updates
513+
valid = self._handle_parameter_value_update(param, new_value, include_range_check=True)
488514

489515
if valid:
490516
logging_debug(_("Parameter %s changed, will later ask if change(s) should be saved to file."), param.name)
@@ -538,24 +564,9 @@ def on_close() -> None:
538564
)
539565
return
540566

541-
valid = True
542-
# Update the parameter value and entry text
543-
try:
544-
param.set_new_value(BitmaskHelper.get_value_from_keys(checked_keys))
545-
except ParameterOutOfRangeError as oor: # user-visible warning from model
546-
# Ask the user if they want to accept the out-of-range value
547-
if messagebox.askyesno(_("Unknown bit set"), str(oor) + _(" Use out-of-range value?"), icon="warning"):
548-
# Retry accepting the value while telling the model to ignore range checks
549-
param.set_new_value(BitmaskHelper.get_value_from_keys(checked_keys), ignore_out_of_range=True)
550-
else:
551-
valid = False
552-
except ParameterUnchangedError:
553-
# Valid but no change — just refresh the UI and do not mark edited
554-
valid = False
555-
except (ValueError, TypeError) as exc: # invalid input according to model
556-
logging_exception(_("Could not set bitmask value for %s: %s"), param.name, exc)
557-
messagebox.showerror(_("Error"), str(exc))
558-
valid = False
567+
# Use centralized error handling for parameter value updates
568+
bitmask_value = BitmaskHelper.get_value_from_keys(checked_keys)
569+
valid = self._handle_parameter_value_update(param, str(bitmask_value), include_range_check=True)
559570

560571
if valid:
561572
show_tooltip(change_reason_widget, param.tooltip_change_reason)

tests/gui_frontend_tkinter_parameter_editor_table.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,52 @@
2323
from conftest import PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED, PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE
2424

2525
from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager
26-
from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter
26+
from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter, Par
27+
from ardupilot_methodic_configurator.data_model_par_dict import ParDict
2728
from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox
2829
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table import ParameterEditorTable
2930

3031
# pylint: disable=protected-access
3132

3233

34+
def create_mock_data_model_ardupilot_parameter( # pylint: disable=too-many-arguments,too-many-positional-arguments # noqa: PLR0913
35+
name: str = "TEST_PARAM",
36+
value: float = 1.0,
37+
default_value: float | None = None,
38+
comment: str = "test comment",
39+
metadata: dict | None = None,
40+
fc_value: float | None = None,
41+
is_forced: bool = False,
42+
is_calibration: bool = False,
43+
is_readonly: bool = False,
44+
min_value: float | None = None,
45+
max_value: float | None = None,
46+
) -> ArduPilotParameter:
47+
"""Create a mock ArduPilotParameter for testing in GUI workflows."""
48+
metadata = metadata or {}
49+
50+
if is_calibration:
51+
metadata["Calibration"] = True
52+
if is_readonly:
53+
metadata["ReadOnly"] = True
54+
if min_value is not None:
55+
metadata["min"] = min_value
56+
if max_value is not None:
57+
metadata["max"] = max_value
58+
59+
metadata.setdefault("unit", "")
60+
metadata.setdefault("doc_tooltip", "Test tooltip")
61+
metadata.setdefault("unit_tooltip", "Unit tooltip")
62+
63+
par_obj = Par(value, comment)
64+
default_par = Par(default_value if default_value is not None else 0.0, "default")
65+
forced_par = Par(value, "forced comment") if is_forced else None
66+
67+
return ArduPilotParameter(
68+
name=name, par_obj=par_obj, metadata=metadata, default_par=default_par, fc_value=fc_value, forced_par=forced_par
69+
)
70+
71+
3372
class TestParameterEditorTableUserWorkflows:
3473
"""Test user workflows and behaviors for ParameterEditorTable GUI components."""
3574

@@ -354,3 +393,104 @@ def test_user_can_work_with_fully_populated_parameter_table(self, parameter_tabl
354393
verify the building blocks work correctly.
355394
"""
356395
pytest.skip("Full table population requires complex parameter data setup - focus on component testing instead")
396+
397+
def test_user_can_edit_multiple_parameters_in_complete_workflow(self, parameter_table: ParameterEditorTable) -> None:
398+
"""
399+
User can manage multiple parameters with visual indicators throughout workflow.
400+
401+
GIVEN: A user has multiple parameters with different states
402+
WHEN: Parameters have different values (default vs changed)
403+
THEN: Visual indicators show parameter states correctly
404+
AND: Each parameter maintains independent state
405+
AND: The system handles multiple parameter contexts simultaneously
406+
"""
407+
# Arrange: Create parameters with different value states
408+
param_default = create_mock_data_model_ardupilot_parameter(
409+
name="PARAM_DEFAULT",
410+
value=10.0,
411+
default_value=10.0, # Same as default
412+
)
413+
param_changed = create_mock_data_model_ardupilot_parameter(
414+
name="PARAM_CHANGED",
415+
value=20.0,
416+
default_value=15.0, # Different from default
417+
)
418+
419+
# Verify: Parameters have correct comparison states
420+
assert param_default.new_value_equals_default_value is True # Default
421+
assert param_changed.new_value_equals_default_value is False # Changed
422+
423+
# Verify: Configuration manager can handle multiple parameters
424+
assert parameter_table.configuration_manager.current_file == "04_board_orientation.param"
425+
assert parameter_table.configuration_manager.is_fc_connected is False
426+
427+
def test_user_can_switch_between_gui_complexity_modes_seamlessly(self, parameter_table: ParameterEditorTable) -> None:
428+
"""
429+
User can work with different GUI complexity modes.
430+
431+
GIVEN: A user switches between GUI complexity modes
432+
WHEN: The table needs to adapt to show/hide upload column
433+
THEN: Upload column visibility changes based on complexity level
434+
AND: Simple mode hides advanced features
435+
AND: Advanced/Expert modes show full functionality
436+
"""
437+
# Verify: Simple mode hides upload column
438+
assert parameter_table._should_show_upload_column("simple") is False
439+
440+
# Verify: Advanced mode shows upload column
441+
assert parameter_table._should_show_upload_column("advanced") is True
442+
443+
# Verify: Expert mode shows upload column
444+
assert parameter_table._should_show_upload_column("expert") is True
445+
446+
# Verify: Change reason column index adapts to upload column visibility
447+
change_reason_idx_simple = parameter_table._get_change_reason_column_index(show_upload_column=False)
448+
change_reason_idx_advanced = parameter_table._get_change_reason_column_index(show_upload_column=True)
449+
450+
# Verify: Column index is one less without upload column
451+
assert change_reason_idx_advanced == change_reason_idx_simple + 1
452+
453+
def test_user_recovers_gracefully_from_validation_errors(self, parameter_table: ParameterEditorTable) -> None:
454+
"""
455+
User receives clear feedback for validation errors and can recover.
456+
457+
GIVEN: A user enters parameter values
458+
WHEN: Values are outside allowed ranges
459+
THEN: System provides clear error handling
460+
AND: Valid values are accepted
461+
AND: Invalid values trigger appropriate error responses
462+
"""
463+
# Arrange: Create parameter with validation constraints
464+
param_constrained = create_mock_data_model_ardupilot_parameter(
465+
name="CONSTRAINED_PARAM", value=50.0, default_value=50.0, min_value=0.0, max_value=100.0
466+
)
467+
468+
# Configure test file parameters
469+
parameter_table.configuration_manager._local_filesystem.file_parameters = ParDict(
470+
{"04_board_orientation.param": ParDict({"CONSTRAINED_PARAM": Par(50.0, "constrained")})}
471+
)
472+
473+
# Act & Verify: Attempt out-of-range value with rejection
474+
with patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.messagebox") as mock_msgbox:
475+
mock_msgbox.askyesno.return_value = False # User rejects invalid value
476+
477+
result_invalid = parameter_table._handle_parameter_value_update(
478+
param_constrained,
479+
"150.0", # Out of range
480+
include_range_check=True,
481+
)
482+
483+
# Verify: Invalid value rejected
484+
assert result_invalid is False
485+
mock_msgbox.askyesno.assert_called_once()
486+
487+
# Act & Verify: Valid value accepted
488+
with patch("ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table.show_tooltip"):
489+
result_valid = parameter_table._handle_parameter_value_update(
490+
param_constrained,
491+
"75.0", # Valid value within range
492+
include_range_check=True,
493+
)
494+
495+
# Verify: Valid value accepted
496+
assert result_valid is True

0 commit comments

Comments
 (0)