Skip to content

Commit 40a6a23

Browse files
committed
test(ESC): Added component editor ESC connection interdependencies tests
1 parent c29523a commit 40a6a23

7 files changed

Lines changed: 2080 additions & 54 deletions

tests/gui_frontend_tkinter_component_editor.py

Lines changed: 1791 additions & 0 deletions
Large diffs are not rendered by default.

tests/test_data_model_vehicle_components_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1668,7 +1668,7 @@ def test_user_sees_notes_last_across_all_components_simultaneously(self, model_i
16681668
"ESC": {
16691669
"Product": {"Manufacturer": "Mamba"},
16701670
"FC->ESC Connection": {"Type": "Main Out", "Protocol": "DShot600"},
1671-
"ESC->FC Telemetry": {"Type": "Main Out", "Protocol": "BDShot"},
1671+
"ESC->FC Telemetry": {"Type": "Main Out", "Protocol": "BDShotOnly"},
16721672
"Notes": "esc notes",
16731673
},
16741674
"Motors": {

tests/test_data_model_vehicle_components_import.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ def test_system_handles_invalid_serial_protocol_value(self, realistic_model) ->
255255
assert result is False
256256

257257
@patch(
258-
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict",
259-
lambda *_: {"6": {"protocol": "DShot600"}},
258+
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict",
259+
lambda *_: {"6": {"protocol": "DShot600", "ESC_to_FC": {}, "type": ("Main Out", "AIO")}},
260260
)
261261
def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None:
262262
"""
@@ -281,8 +281,10 @@ def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None:
281281
assert esc_protocol == "DShot600"
282282

283283
@patch(
284-
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict",
285-
lambda *_: {"6": {"protocol": "DShot600"}},
284+
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict",
285+
lambda *_: {
286+
"6": {"protocol": "DShot600", "ESC_to_FC": {("same_as_FC_to_ESC",): "BDShotOnly"}, "type": ("Main Out", "AIO")}
287+
},
286288
)
287289
def test_system_imports_esc_aio_configuration(self, realistic_model) -> None:
288290
"""
@@ -479,16 +481,18 @@ def test_system_skips_disabled_serial_ports(self, realistic_model) -> None:
479481

480482
def test_system_falls_back_to_mot_pwm_dict_when_doc_empty(self, realistic_model) -> None:
481483
"""
482-
System falls back to MOT_PWM_TYPE_DICT when documentation is empty.
484+
System falls back to ESC_CONNECTION_DICT when documentation is empty.
483485
484-
GIVEN: Empty documentation but MOT_PWM_TYPE_DICT available
486+
GIVEN: Empty documentation but ESC_CONNECTION_DICT available
485487
WHEN: Importing ESC configuration
486-
THEN: MOT_PWM_TYPE_DICT should be used as fallback
488+
THEN: ESC_CONNECTION_DICT should be used as fallback
487489
AND: ESC protocol should be correctly identified
488490
"""
489491
with patch(
490-
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict",
491-
return_value={"6": {"protocol": "DShot600"}},
492+
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict",
493+
return_value={
494+
"6": {"protocol": "DShot600", "ESC_to_FC": {("same_as_FC_to_ESC",): "BDShotOnly"}, "type": ("Main Out", "AIO")}
495+
},
492496
):
493497
fc_parameters = {"MOT_PWM_TYPE": 6}
494498
doc: dict[str, Any] = {} # Empty doc should trigger fallback
@@ -502,7 +506,7 @@ def test_system_handles_esc_protocol_not_found(self, realistic_model) -> None:
502506
"""
503507
System handles ESC protocol not found in either documentation or dictionary.
504508
505-
GIVEN: MOT_PWM_TYPE not in documentation or MOT_PWM_TYPE_DICT
509+
GIVEN: MOT_PWM_TYPE not in documentation or ESC_CONNECTION_DICT
506510
WHEN: Importing ESC configuration
507511
THEN: System should handle gracefully without setting protocol
508512
"""
@@ -912,8 +916,10 @@ def test_system_handles_dshot_without_poles_parameter(self, realistic_model) ->
912916
THEN: System should handle gracefully without setting poles
913917
"""
914918
with patch(
915-
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict",
916-
lambda *_: {"6": {"protocol": "DShot600", "is_dshot": True}},
919+
"ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict",
920+
lambda *_: {
921+
"6": {"protocol": "DShot600", "type": ("Main Out", "AIO"), "ESC_to_FC": {("same_as_FC_to_ESC",): "BDShotOnly"}}
922+
},
917923
):
918924
fc_parameters = {
919925
"MOT_PWM_TYPE": 6,
@@ -2080,12 +2086,12 @@ def test_system_skips_setting_esc_protocol_when_doc_value_is_absent(self, basic_
20802086

20812087
def test_system_uses_mot_pwm_type_dict_fallback_when_doc_has_no_mot_pwm_type(self, basic_model) -> None:
20822088
"""
2083-
_set_esc_type_from_fc_parameters falls back to MOT_PWM_TYPE_DICT when doc lacks values.
2089+
_set_esc_type_from_fc_parameters falls back to ESC_CONNECTION_DICT when doc lacks values.
20842090
20852091
GIVEN: doc has no MOT_PWM_TYPE entry (empty)
2086-
AND: fc_parameters has MOT_PWM_TYPE=0 (which IS in MOT_PWM_TYPE_DICT as 'Normal')
2092+
AND: fc_parameters has MOT_PWM_TYPE=0 (which IS in ESC_CONNECTION_DICT as 'Normal')
20872093
WHEN: _set_esc_type_from_fc_parameters is called
2088-
THEN: Protocol is set to 'Normal' via the elif fallback (line 335 exercised)
2094+
THEN: Protocol is set to 'Normal' via the elif fallback
20892095
"""
20902096
fc_parameters: dict[str, Any] = {"MOT_PWM_TYPE": 0}
20912097
doc: dict[str, Any] = {} # no MOT_PWM_TYPE in doc
@@ -2230,9 +2236,9 @@ def test_system_leaves_esc_protocol_unchanged_when_type_not_in_doc_or_dict(self,
22302236
_set_esc_type_from_fc_parameters leaves ESC protocol unchanged when mot_pwm_type is unknown.
22312237
22322238
GIVEN: doc is empty (no MOT_PWM_TYPE key)
2233-
AND: fc_parameters has MOT_PWM_TYPE=999 (not in MOT_PWM_TYPE_DICT either)
2239+
AND: fc_parameters has MOT_PWM_TYPE=999 (not in ESC_CONNECTION_DICT either)
22342240
WHEN: _set_esc_type_from_fc_parameters is called
2235-
THEN: Neither the doc branch nor the dict fallback sets the protocol (line 335->exit exercised)
2241+
THEN: Neither the doc branch nor the dict fallback sets the protocol
22362242
AND: The function completes without error
22372243
"""
22382244
fc_parameters: dict[str, Any] = {"MOT_PWM_TYPE": 999} # not in dict

tests/test_frontend_tkinter_component_editor.py

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@
2323
setup_common_editor_mocks,
2424
)
2525

26-
from ardupilot_methodic_configurator.data_model_vehicle_components_validation import BATTERY_CELL_VOLTAGE_PATHS
26+
from ardupilot_methodic_configurator.data_model_vehicle_components_validation import (
27+
BATTERY_CELL_VOLTAGE_PATHS,
28+
get_connection_type_tuples_with_labels,
29+
)
2730
from ardupilot_methodic_configurator.frontend_tkinter_component_editor import ComponentEditorWindow
2831
from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox
2932

30-
# pylint: disable=protected-access,redefined-outer-name
33+
# pylint: disable=protected-access,redefined-outer-name,too-many-lines
3134

3235

3336
@pytest.fixture
@@ -57,6 +60,24 @@ def editor_with_mocked_root() -> ComponentEditorWindow:
5760
yield editor
5861

5962

63+
class _FakePairTupleCombobox(PairTupleCombobox): # pylint: disable=too-many-ancestors
64+
"""Lightweight PairTupleCombobox-compatible test double."""
65+
66+
def __init__(self, selected_key: str) -> None: # pylint: disable=super-init-not-called
67+
self._selected_key = selected_key
68+
self.list_keys: list[str] = [selected_key] if selected_key else []
69+
self.configure = MagicMock()
70+
self.update_idletasks = MagicMock()
71+
self.set_entries_tuple = MagicMock(side_effect=self._set_entries_tuple)
72+
73+
def _set_entries_tuple(self, entries: list[tuple[str, str]], selection: str = "") -> None:
74+
self.list_keys = [key for key, _ in entries]
75+
self._selected_key = selection or ""
76+
77+
def get_selected_key(self) -> str:
78+
return self._selected_key
79+
80+
6081
class TestComponentEditorWindow: # pylint: disable=too-many-public-methods
6182
"""Test cases for ComponentEditorWindow class."""
6283

@@ -831,3 +852,169 @@ def mock_set_entries_tuple(entries, selection) -> None:
831852
# If SBUS is valid for RCin/SBUS, no error should occur
832853
mock_show_error.assert_not_called()
833854
assert result == ""
855+
856+
def test_esc_fc_telemetry_type_widget_refreshes_to_same_serial_port_when_fc_esc_connection_changes_to_serial(
857+
self, editor_with_mocked_root
858+
) -> None:
859+
"""
860+
ESC->FC Telemetry Type widget is refreshed to the new serial port when FC->ESC Connection type changes.
861+
862+
GIVEN: ESC FC->ESC Connection Type combobox is changed to a SERIAL port (e.g. SERIAL1)
863+
WHEN: update_component_protocol_combobox_entries is called with the new SERIAL port
864+
THEN: The ESC->FC Telemetry Type combobox is updated to display only that same SERIAL port
865+
AND: The displayed selection in the widget matches the SERIAL port that was chosen
866+
"""
867+
editor = editor_with_mocked_root
868+
serial_port = "SERIAL1"
869+
fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type")
870+
fc_esc_proto_path = ("ESC", "FC->ESC Connection", "Protocol")
871+
telem_type_path = ("ESC", "ESC->FC Telemetry", "Type")
872+
telem_proto_path = ("ESC", "ESC->FC Telemetry", "Protocol")
873+
874+
fc_esc_protocol = "FETtecOneWire"
875+
876+
# GIVEN: Data model cascade returns SERIAL1 as the only valid Telemetry Type option
877+
# (what _update_esc_fc_connection_choices produces when FC->ESC is SERIAL)
878+
def combobox_values_for_path(path) -> tuple:
879+
return {
880+
fc_esc_proto_path: (fc_esc_protocol,),
881+
telem_type_path: (serial_port,),
882+
telem_proto_path: ("None",),
883+
}.get(path, ())
884+
885+
editor.data_model.get_combobox_values_for_path.side_effect = combobox_values_for_path
886+
editor.data_model.get_component_value = MagicMock(
887+
side_effect=lambda path: fc_esc_protocol if path == fc_esc_proto_path else serial_port
888+
)
889+
890+
# GIVEN: PairTupleCombobox-compatible test doubles are registered for all three paths
891+
fc_esc_proto_widget = _FakePairTupleCombobox(fc_esc_protocol)
892+
telem_type_widget = _FakePairTupleCombobox(serial_port)
893+
telem_proto_widget = _FakePairTupleCombobox("None")
894+
895+
editor.entry_widgets[fc_esc_proto_path] = fc_esc_proto_widget
896+
editor.entry_widgets[telem_type_path] = telem_type_widget
897+
editor.entry_widgets[telem_proto_path] = telem_proto_widget
898+
899+
# WHEN: User changes FC->ESC Connection Type to SERIAL1
900+
editor.update_component_protocol_combobox_entries(fc_esc_type_path, serial_port)
901+
902+
# THEN: ESC->FC Telemetry Type widget is refreshed with only the matching SERIAL port
903+
telem_type_widget.set_entries_tuple.assert_called_once_with(
904+
get_connection_type_tuples_with_labels((serial_port,)), serial_port
905+
)
906+
907+
def test_esc_fc_telemetry_comboboxes_are_disabled_when_fc_esc_connection_type_is_serial(
908+
self, editor_with_mocked_root
909+
) -> None:
910+
"""
911+
ESC->FC Telemetry comboboxes are greyed-out (disabled) when FC->ESC Connection type is SERIAL.
912+
913+
GIVEN: ESC FC->ESC Connection Type is changed to a SERIAL port
914+
WHEN: update_component_protocol_combobox_entries is called
915+
THEN: Both ESC->FC Telemetry Type and Protocol comboboxes are set to state="disabled"
916+
AND: The user cannot independently change the Telemetry port (it is locked to the FC->ESC port)
917+
"""
918+
editor = editor_with_mocked_root
919+
serial_port = "SERIAL2"
920+
fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type")
921+
fc_esc_proto_path = ("ESC", "FC->ESC Connection", "Protocol")
922+
telem_type_path = ("ESC", "ESC->FC Telemetry", "Type")
923+
telem_proto_path = ("ESC", "ESC->FC Telemetry", "Protocol")
924+
925+
fc_esc_protocol = "FETtecOneWire"
926+
927+
# GIVEN: Data model cascade returns the SERIAL port for both Telemetry fields
928+
def combobox_values_for_path(path) -> tuple:
929+
return {
930+
fc_esc_proto_path: (fc_esc_protocol,),
931+
telem_type_path: (serial_port,),
932+
telem_proto_path: ("None",),
933+
}.get(path, ())
934+
935+
editor.data_model.get_combobox_values_for_path.side_effect = combobox_values_for_path
936+
editor.data_model.get_component_value = MagicMock(
937+
side_effect=lambda path: fc_esc_protocol if path == fc_esc_proto_path else serial_port
938+
)
939+
940+
# GIVEN: PairTupleCombobox-compatible test doubles registered for the telemetry paths
941+
fc_esc_proto_widget = _FakePairTupleCombobox(fc_esc_protocol)
942+
telem_type_widget = _FakePairTupleCombobox(serial_port)
943+
telem_proto_widget = _FakePairTupleCombobox("None")
944+
945+
editor.entry_widgets[fc_esc_proto_path] = fc_esc_proto_widget
946+
editor.entry_widgets[telem_type_path] = telem_type_widget
947+
editor.entry_widgets[telem_proto_path] = telem_proto_widget
948+
949+
# WHEN: User changes FC->ESC Connection Type to a SERIAL port
950+
editor.update_component_protocol_combobox_entries(fc_esc_type_path, serial_port)
951+
952+
# THEN: Both ESC->FC Telemetry comboboxes are disabled (user cannot change them independently)
953+
telem_type_widget.configure.assert_any_call(state="disabled")
954+
telem_proto_widget.configure.assert_any_call(state="disabled")
955+
956+
def test_esc_fc_telemetry_protocol_mirrors_fc_esc_protocol_when_type_changes_to_serial(
957+
self, editor_with_mocked_root
958+
) -> None:
959+
"""
960+
ESC->FC Telemetry Protocol is mirrored to FC->ESC Protocol when FC->ESC Type changes to SERIAL.
961+
962+
GIVEN: FC->ESC Protocol is already set to "FETtecOneWire"
963+
WHEN: FC->ESC Connection Type is changed to SERIAL1
964+
THEN: ESC->FC Telemetry Protocol widget is updated to display "FETtecOneWire" (not "None")
965+
AND: The widget selection is set to the same protocol, not left stale
966+
"""
967+
editor = editor_with_mocked_root
968+
serial_port = "SERIAL1"
969+
fc_esc_protocol = "FETtecOneWire"
970+
fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type")
971+
fc_esc_proto_path = ("ESC", "FC->ESC Connection", "Protocol")
972+
telem_type_path = ("ESC", "ESC->FC Telemetry", "Type")
973+
telem_proto_path = ("ESC", "ESC->FC Telemetry", "Protocol")
974+
975+
# GIVEN: Data model returns the correct cascaded values after the Type change
976+
def combobox_values_for_path(path) -> tuple:
977+
return {
978+
fc_esc_proto_path: (fc_esc_protocol,),
979+
telem_type_path: (serial_port,),
980+
telem_proto_path: ("None", fc_esc_protocol),
981+
}.get(path, ())
982+
983+
editor.data_model.get_combobox_values_for_path.side_effect = combobox_values_for_path
984+
# _on_esc_fc_protocol_changed reads the FC->ESC Connection Type and Protocol from data model
985+
editor.data_model.get_component_value = MagicMock(
986+
side_effect=lambda path: fc_esc_protocol if path == fc_esc_proto_path else serial_port
987+
)
988+
989+
fc_esc_proto_widget = _FakePairTupleCombobox(fc_esc_protocol)
990+
telem_type_widget = _FakePairTupleCombobox(serial_port)
991+
992+
# ESC->FC Protocol was previously "None" — this is the stale value the bug leaves
993+
telem_proto_widget = _FakePairTupleCombobox("None")
994+
995+
editor.entry_widgets[fc_esc_proto_path] = fc_esc_proto_widget
996+
editor.entry_widgets[telem_type_path] = telem_type_widget
997+
editor.entry_widgets[telem_proto_path] = telem_proto_widget
998+
999+
# WHEN: User changes FC->ESC Connection Type to SERIAL1
1000+
editor.update_component_protocol_combobox_entries(fc_esc_type_path, serial_port)
1001+
1002+
# THEN: ESC->FC Telemetry Protocol widget is set to the same protocol as FC->ESC
1003+
telem_proto_widget.set_entries_tuple.assert_called_with([(fc_esc_protocol, fc_esc_protocol)], fc_esc_protocol)
1004+
1005+
def test_update_protocol_combobox_entries_autoselects_only_option_when_selection_is_empty(
1006+
self, editor_with_mocked_root
1007+
) -> None:
1008+
"""Single-option protocol comboboxes auto-select their sole value even when no current selection exists."""
1009+
editor = editor_with_mocked_root
1010+
protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol")
1011+
protocol_widget = _FakePairTupleCombobox("")
1012+
1013+
editor.entry_widgets[protocol_path] = protocol_widget
1014+
1015+
result = editor.update_protocol_combobox_entries(("DroneCAN",), protocol_path)
1016+
1017+
assert result == ""
1018+
editor.data_model.set_component_value.assert_any_call(protocol_path, "DroneCAN")
1019+
assert protocol_widget.list_keys == ["DroneCAN"]
1020+
assert protocol_widget.get_selected_key() == "DroneCAN"

tests/test_frontend_tkinter_component_editor_integration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ def test_editor_initialization_process(self, temp_vehicle_dir, root) -> None:
293293
patch.object(ComponentEditorWindow, "_create_save_frame") as mock_save_frame,
294294
patch("tkinter.PhotoImage"),
295295
patch("PIL.ImageTk.PhotoImage"),
296+
patch("tkinter.Tk", return_value=root),
296297
):
297298
# Create the editor with real filesystem and data model
298299
editor = ComponentEditorWindow("1.0.0", filesystem, {})
@@ -344,6 +345,7 @@ def test_editor_with_real_data_model(self, temp_vehicle_dir, root) -> None:
344345
patch.object(ComponentEditorWindow, "_initialize_ui"),
345346
patch("tkinter.PhotoImage"),
346347
patch("PIL.ImageTk.PhotoImage"),
348+
patch("tkinter.Tk", return_value=root),
347349
):
348350
# Initialize with existing data_model using dependency injection
349351
editor = ComponentEditorWindow("1.0.0", filesystem, {})

0 commit comments

Comments
 (0)