|
19 | 19 | import pytest |
20 | 20 |
|
21 | 21 | from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem |
| 22 | +from ardupilot_methodic_configurator.data_model_par_dict import ParDict |
22 | 23 | from ardupilot_methodic_configurator.data_model_vehicle_project import VehicleProjectManager |
23 | 24 | from ardupilot_methodic_configurator.data_model_vehicle_project_creator import ( |
24 | 25 | NewVehicleProjectSettings, |
@@ -1027,3 +1028,298 @@ def test_user_can_complete_vehicle_opening_workflow(self) -> None: |
1027 | 1028 | assert vehicle_path == "/opened/vehicle/path" |
1028 | 1029 | mock_open.assert_called_once_with("/vehicle/path") |
1029 | 1030 | mock_store.assert_called_once_with("/opened/vehicle/path") |
| 1031 | + |
| 1032 | + |
| 1033 | +class TestCreateNewVehicleFromBinLog: |
| 1034 | + """Test the create_new_vehicle_from_bin_log orchestration method.""" |
| 1035 | + |
| 1036 | + def _make_manager(self, with_fc: bool = False) -> "VehicleProjectManager": |
| 1037 | + mock_filesystem = MagicMock(spec=LocalFilesystem) |
| 1038 | + mock_flight_controller = MagicMock() if with_fc else None |
| 1039 | + return VehicleProjectManager(mock_filesystem, mock_flight_controller) |
| 1040 | + |
| 1041 | + def test_user_can_create_project_from_bin_log_successfully(self) -> None: |
| 1042 | + """ |
| 1043 | + User can create a new vehicle project from a valid .bin log file. |
| 1044 | +
|
| 1045 | + GIVEN: A project manager and a valid .bin log file |
| 1046 | + WHEN: create_new_vehicle_from_bin_log is called |
| 1047 | + THEN: The vehicle directory is created, defaults replaced, and the path returned |
| 1048 | + """ |
| 1049 | + # Arrange |
| 1050 | + manager = self._make_manager() |
| 1051 | + |
| 1052 | + fake_defaults = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1053 | + fake_current = ParDict.from_float_dict({"PARAM_A": 1.0, "PARAM_B": 2.0}) |
| 1054 | + empty_compound = ParDict.from_float_dict({}) |
| 1055 | + |
| 1056 | + with ( |
| 1057 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl/ArduCopter/empty_4.6.x"), |
| 1058 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="my_flight"), |
| 1059 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(fake_defaults, fake_current)), |
| 1060 | + patch.object( |
| 1061 | + manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/my_flight" |
| 1062 | + ) as mock_create, |
| 1063 | + patch.object(manager._creator, "next_import_filename", return_value="02_imported_bin_log_parameters.param"), |
| 1064 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1065 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1066 | + patch.object(manager, "open_vehicle_directory") as mock_open, |
| 1067 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file") as mock_write, |
| 1068 | + patch.object(manager._local_filesystem, "compound_params", return_value=(empty_compound, "00_default.param")), |
| 1069 | + patch.object(manager._local_filesystem, "export_to_param"), |
| 1070 | + patch.object(manager._local_filesystem, "re_init"), |
| 1071 | + ): |
| 1072 | + # Act |
| 1073 | + result = manager.create_new_vehicle_from_bin_log("/logs/my_flight.bin") |
| 1074 | + |
| 1075 | + # Assert: correct path returned |
| 1076 | + assert result == "/vehicles/my_flight" |
| 1077 | + # Assert: template creation called with fc_connected=False (key difference from normal flow) |
| 1078 | + _args, kwargs = mock_create.call_args |
| 1079 | + assert kwargs.get("fc_connected") is False |
| 1080 | + # Assert: vehicle directory opened immediately after creation |
| 1081 | + mock_open.assert_called_once_with("/vehicles/my_flight") |
| 1082 | + # Assert: extracted defaults written (replacing the template's 00_default.param) |
| 1083 | + mock_write.assert_called_once_with(fake_defaults) |
| 1084 | + |
| 1085 | + def test_bin_log_defaults_are_written_to_vehicle_directory(self) -> None: |
| 1086 | + """ |
| 1087 | + The defaults extracted from the .bin log replace the template's 00_default.param. |
| 1088 | +
|
| 1089 | + GIVEN: A valid .bin log file with a known defaults snapshot |
| 1090 | + WHEN: create_new_vehicle_from_bin_log is called |
| 1091 | + THEN: write_param_default_values_to_file is called with the extracted defaults ParDict |
| 1092 | + """ |
| 1093 | + # Arrange |
| 1094 | + manager = self._make_manager() |
| 1095 | + |
| 1096 | + fake_defaults = ParDict.from_float_dict({"BARO_ALT_OFFSET": 0.0}) |
| 1097 | + fake_current = ParDict.from_float_dict({"BARO_ALT_OFFSET": 0.5}) |
| 1098 | + empty_compound = ParDict.from_float_dict({}) |
| 1099 | + |
| 1100 | + with ( |
| 1101 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl"), |
| 1102 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="flight"), |
| 1103 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(fake_defaults, fake_current)), |
| 1104 | + patch.object(manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/flight"), |
| 1105 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1106 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1107 | + patch.object(manager, "open_vehicle_directory"), |
| 1108 | + patch.object(manager._local_filesystem, "compound_params", return_value=(empty_compound, "00_default.param")), |
| 1109 | + patch.object(manager._creator, "next_import_filename", return_value="02_imported_bin_log_parameters.param"), |
| 1110 | + patch.object(manager._local_filesystem, "export_to_param"), |
| 1111 | + patch.object(manager._local_filesystem, "re_init"), |
| 1112 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file") as mock_write, |
| 1113 | + ): |
| 1114 | + manager.create_new_vehicle_from_bin_log("/logs/flight.bin") |
| 1115 | + |
| 1116 | + # Assert: the extracted defaults — not the template's — are written |
| 1117 | + mock_write.assert_called_once_with(fake_defaults) |
| 1118 | + |
| 1119 | + def test_missing_params_exported_to_import_file(self) -> None: |
| 1120 | + """ |
| 1121 | + Parameters present in the .bin log but absent from the AMC files are exported. |
| 1122 | +
|
| 1123 | + GIVEN: A .bin log where current params include entries not covered by AMC files |
| 1124 | + WHEN: create_new_vehicle_from_bin_log is called |
| 1125 | + THEN: export_to_param is called for the difference, and the filesystem is re-initialised |
| 1126 | + """ |
| 1127 | + # Arrange |
| 1128 | + manager = self._make_manager() |
| 1129 | + |
| 1130 | + fake_defaults = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1131 | + fake_current = ParDict.from_float_dict({"PARAM_A": 1.0, "EXTRA_PARAM": 99.0}) |
| 1132 | + # compound_params covers only PARAM_A — EXTRA_PARAM is missing |
| 1133 | + compound = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1134 | + |
| 1135 | + with ( |
| 1136 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl"), |
| 1137 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="flight"), |
| 1138 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(fake_defaults, fake_current)), |
| 1139 | + patch.object(manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/flight"), |
| 1140 | + patch.object(manager._creator, "next_import_filename", return_value="02_imported_bin_log_parameters.param"), |
| 1141 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1142 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1143 | + patch.object(manager, "open_vehicle_directory"), |
| 1144 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file"), |
| 1145 | + patch.object(manager._local_filesystem, "compound_params", return_value=(compound, "00_default.param")), |
| 1146 | + patch.object(manager._local_filesystem, "export_to_param") as mock_export, |
| 1147 | + patch.object(manager._local_filesystem, "re_init") as mock_reinit, |
| 1148 | + ): |
| 1149 | + manager.create_new_vehicle_from_bin_log("/logs/flight.bin") |
| 1150 | + |
| 1151 | + # Assert: the import file is created and the filesystem is re-initialised |
| 1152 | + mock_export.assert_called_once() |
| 1153 | + exported_params, export_filename = mock_export.call_args.args[:2] |
| 1154 | + assert export_filename == "02_imported_bin_log_parameters.param" |
| 1155 | + assert "EXTRA_PARAM" in exported_params |
| 1156 | + assert "PARAM_A" not in exported_params |
| 1157 | + assert mock_export.call_args.kwargs.get("annotate_doc") is False |
| 1158 | + mock_reinit.assert_called_once() |
| 1159 | + |
| 1160 | + def test_no_import_file_when_all_params_covered_by_amc_files(self) -> None: |
| 1161 | + """ |
| 1162 | + No extra import file is created when all current params are already in AMC files. |
| 1163 | +
|
| 1164 | + GIVEN: A .bin log where all current params match the AMC param files |
| 1165 | + WHEN: create_new_vehicle_from_bin_log is called |
| 1166 | + THEN: export_to_param and re_init are NOT called |
| 1167 | + """ |
| 1168 | + # Arrange |
| 1169 | + manager = self._make_manager() |
| 1170 | + |
| 1171 | + params = ParDict.from_float_dict({"PARAM_A": 1.0, "PARAM_B": 2.0}) |
| 1172 | + compound = ParDict.from_float_dict({"PARAM_A": 1.0, "PARAM_B": 2.0}) |
| 1173 | + |
| 1174 | + with ( |
| 1175 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl"), |
| 1176 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="flight"), |
| 1177 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(params, params)), |
| 1178 | + patch.object(manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/flight"), |
| 1179 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1180 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1181 | + patch.object(manager, "open_vehicle_directory"), |
| 1182 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file"), |
| 1183 | + patch.object(manager._local_filesystem, "compound_params", return_value=(compound, "00_default.param")), |
| 1184 | + patch.object(manager._local_filesystem, "export_to_param") as mock_export, |
| 1185 | + patch.object(manager._local_filesystem, "re_init") as mock_reinit, |
| 1186 | + ): |
| 1187 | + manager.create_new_vehicle_from_bin_log("/logs/flight.bin") |
| 1188 | + |
| 1189 | + # Assert: no import file written, no re-init |
| 1190 | + mock_export.assert_not_called() |
| 1191 | + mock_reinit.assert_not_called() |
| 1192 | + |
| 1193 | + def test_fc_parameters_synced_when_flight_controller_connected(self) -> None: |
| 1194 | + """ |
| 1195 | + When a flight controller is connected, its fc_parameters are updated. |
| 1196 | +
|
| 1197 | + GIVEN: A project manager with an active flight controller |
| 1198 | + WHEN: create_new_vehicle_from_bin_log completes successfully |
| 1199 | + THEN: The flight controller's fc_parameters are set to the current log params |
| 1200 | + """ |
| 1201 | + # Arrange |
| 1202 | + manager = self._make_manager(with_fc=True) |
| 1203 | + |
| 1204 | + fake_defaults = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1205 | + fake_current = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1206 | + compound = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1207 | + |
| 1208 | + with ( |
| 1209 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl"), |
| 1210 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="flight"), |
| 1211 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(fake_defaults, fake_current)), |
| 1212 | + patch.object(manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/flight"), |
| 1213 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1214 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1215 | + patch.object(manager, "open_vehicle_directory"), |
| 1216 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file"), |
| 1217 | + patch.object(manager._local_filesystem, "compound_params", return_value=(compound, "00_default.param")), |
| 1218 | + patch.object(manager._local_filesystem, "export_to_param"), |
| 1219 | + patch.object(manager._local_filesystem, "re_init"), |
| 1220 | + ): |
| 1221 | + manager.create_new_vehicle_from_bin_log("/logs/flight.bin") |
| 1222 | + |
| 1223 | + # Assert: FC parameters updated to the values extracted from the log |
| 1224 | + assert manager._flight_controller.fc_parameters == {"PARAM_A": 1.0} |
| 1225 | + |
| 1226 | + def test_creation_error_propagates_to_caller(self) -> None: |
| 1227 | + """ |
| 1228 | + VehicleProjectCreationError from extraction propagates unchanged. |
| 1229 | +
|
| 1230 | + GIVEN: A .bin log file that cannot be parsed |
| 1231 | + WHEN: create_new_vehicle_from_bin_log is called |
| 1232 | + THEN: VehicleProjectCreationError is raised with the original title/message |
| 1233 | + """ |
| 1234 | + # Arrange |
| 1235 | + manager = self._make_manager() |
| 1236 | + |
| 1237 | + with ( |
| 1238 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl"), |
| 1239 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="bad"), |
| 1240 | + patch.object( |
| 1241 | + manager._creator, |
| 1242 | + "extract_param_files_from_bin_log", |
| 1243 | + side_effect=VehicleProjectCreationError(".bin log import", "Corrupt log"), |
| 1244 | + ), |
| 1245 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1246 | + pytest.raises(VehicleProjectCreationError) as exc_info, |
| 1247 | + ): |
| 1248 | + manager.create_new_vehicle_from_bin_log("/logs/bad.bin") |
| 1249 | + |
| 1250 | + assert exc_info.value.title == ".bin log import" |
| 1251 | + assert exc_info.value.message == "Corrupt log" |
| 1252 | + |
| 1253 | + def test_template_creation_always_called_with_fc_connected_false(self) -> None: |
| 1254 | + """ |
| 1255 | + create_new_vehicle_from_template is always called with fc_connected=False. |
| 1256 | +
|
| 1257 | + This is the key difference from the normal template-creation flow: the vehicle |
| 1258 | + is scaffolded without a live FC connection, using log-extracted params instead. |
| 1259 | +
|
| 1260 | + GIVEN: A project manager that even has a flight controller connected |
| 1261 | + WHEN: create_new_vehicle_from_bin_log is called |
| 1262 | + THEN: create_new_vehicle_from_template receives fc_connected=False |
| 1263 | + """ |
| 1264 | + # Arrange: manager WITH a connected flight controller |
| 1265 | + manager = self._make_manager(with_fc=True) |
| 1266 | + |
| 1267 | + params = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1268 | + compound = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1269 | + |
| 1270 | + with ( |
| 1271 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl"), |
| 1272 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="flight"), |
| 1273 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(params, params)), |
| 1274 | + patch.object(manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/flight") as mock_create, |
| 1275 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1276 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1277 | + patch.object(manager, "open_vehicle_directory"), |
| 1278 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file"), |
| 1279 | + patch.object(manager._local_filesystem, "compound_params", return_value=(compound, "00_default.param")), |
| 1280 | + patch.object(manager._local_filesystem, "export_to_param"), |
| 1281 | + patch.object(manager._local_filesystem, "re_init"), |
| 1282 | + ): |
| 1283 | + manager.create_new_vehicle_from_bin_log("/logs/flight.bin") |
| 1284 | + |
| 1285 | + # Assert: regardless of FC connection, fc_connected must be False |
| 1286 | + _args, kwargs = mock_create.call_args |
| 1287 | + assert kwargs.get("fc_connected") is False |
| 1288 | + |
| 1289 | + def test_manager_state_updated_after_bin_log_import(self) -> None: |
| 1290 | + """ |
| 1291 | + Manager internal state is updated correctly after a successful .bin log import. |
| 1292 | +
|
| 1293 | + GIVEN: A project manager in its initial state |
| 1294 | + WHEN: create_new_vehicle_from_bin_log completes successfully |
| 1295 | + THEN: _settings carries the bin-log import options and configuration_template |
| 1296 | + is set to the template directory name |
| 1297 | + """ |
| 1298 | + # Arrange |
| 1299 | + manager = self._make_manager() |
| 1300 | + |
| 1301 | + params = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1302 | + compound = ParDict.from_float_dict({"PARAM_A": 1.0}) |
| 1303 | + |
| 1304 | + with ( |
| 1305 | + patch.object(manager._creator, "template_dir_for_bin_import", return_value="/tpl/ArduCopter/empty_4.6.x"), |
| 1306 | + patch.object(manager._creator, "vehicle_name_from_bin_log", return_value="flight"), |
| 1307 | + patch.object(manager._creator, "extract_param_files_from_bin_log", return_value=(params, params)), |
| 1308 | + patch.object(manager._creator, "create_new_vehicle_from_template", return_value="/vehicles/flight"), |
| 1309 | + patch.object(LocalFilesystem, "get_vehicles_default_dir", return_value="/vehicles"), |
| 1310 | + patch.object(manager, "store_recently_used_template_dirs"), |
| 1311 | + patch.object(manager, "open_vehicle_directory"), |
| 1312 | + patch.object(manager._local_filesystem, "write_param_default_values_to_file"), |
| 1313 | + patch.object(manager._local_filesystem, "compound_params", return_value=(compound, "00_default.param")), |
| 1314 | + patch.object(manager._local_filesystem, "export_to_param"), |
| 1315 | + patch.object(manager._local_filesystem, "re_init"), |
| 1316 | + ): |
| 1317 | + manager.create_new_vehicle_from_bin_log("/logs/flight.bin") |
| 1318 | + |
| 1319 | + # Assert: settings reflect the bin-log import defaults |
| 1320 | + assert manager._settings is not None |
| 1321 | + assert manager._settings.blank_change_reason is True |
| 1322 | + assert manager._settings.infer_comp_specs_and_conn_from_fc_params is True |
| 1323 | + assert manager._settings.use_fc_params is True |
| 1324 | + # Assert: configuration_template is the leaf directory name of the template path |
| 1325 | + assert manager.configuration_template == "empty_4.6.x" |
0 commit comments