Skip to content

Commit 0384823

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

3 files changed

Lines changed: 905 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: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,59 @@
1717
import tkinter as tk
1818
from collections.abc import Generator
1919
from tkinter import ttk
20+
from typing import Union
2021
from unittest.mock import Mock, patch
2122

2223
import pytest
2324
from conftest import PARAMETER_EDITOR_TABLE_HEADERS_ADVANCED, PARAMETER_EDITOR_TABLE_HEADERS_SIMPLE
2425

2526
from ardupilot_methodic_configurator.configuration_manager import ConfigurationManager
26-
from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter
27+
from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter, Par
28+
from ardupilot_methodic_configurator.data_model_par_dict import ParDict
2729
from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox
2830
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table import ParameterEditorTable
2931

3032
# pylint: disable=protected-access
3133

3234

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

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

0 commit comments

Comments
 (0)