diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05265d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [ '**' ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-24.04 + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install System Dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + cmake \ + git \ + python3 \ + python3-pip \ + python3-venv \ + libgrpc++-dev \ + libprotobuf-dev \ + protobuf-compiler \ + protobuf-compiler-grpc \ + nlohmann-json3-dev + + - name: Set up Python Virtual Environment + run: | + python3 -m venv venv + ./venv/bin/pip install -r generator/requirements.txt + echo "$GITHUB_WORKSPACE/venv/bin" >> $GITHUB_PATH + + - name: Run Generator Parser Tests + run: | + python3 generator/test/test_parser.py + + - name: Run Generator Dialect & Protoc Validation Tests + run: | + python3 generator/test/test_generator.py + + - name: "Verify C++ Build & Run Tests (Dialect: common)" + run: | + cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DMAVLINK_DIALECT=common + cmake --build build -j$(nproc) + cd build && ctest --output-on-failure + + - name: "Verify C++ Build & Run Tests (Dialect: minimal)" + run: | + rm -rf build + cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DMAVLINK_DIALECT=minimal + cmake --build build -j$(nproc) + cd build && ctest --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f2e24a..953f976 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,5 +17,8 @@ if(NOT GEN_RESULT EQUAL 0) message(FATAL_ERROR "MAVLink to Protobuf generation failed!") endif() +# Enable CTest suite +enable_testing() + # Add bridge module subdirectory add_subdirectory(bridge) diff --git a/bridge/CMakeLists.txt b/bridge/CMakeLists.txt index 1b4b464..c3a0a0f 100644 --- a/bridge/CMakeLists.txt +++ b/bridge/CMakeLists.txt @@ -134,4 +134,54 @@ if (CMAKE_BUILD_TYPE STREQUAL "Debug") target_compile_options(${PROJECT_NAME} PRIVATE -O0 -g) else() target_compile_options(${PROJECT_NAME} PRIVATE -O3) -endif() \ No newline at end of file +endif() + +# ============================================================================ +# C++ Testing Target +# ============================================================================ +enable_testing() + +add_executable(test_runner + test/test_runner.cc + test/test_router.cc + test/test_converter.cc + src/service/Router.cc + src/service/Logger.cc + src/mavlink/MessageConverter.cc + $ +) + +target_include_directories(test_runner PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${GENERATED_PROTOBUF_PATH} + ${CMAKE_CURRENT_BINARY_DIR}/generated +) + +target_include_directories(test_runner SYSTEM PRIVATE + ${LIBMAV_INCLUDE_DIR} +) + +add_dependencies(test_runner proto_lib) + +target_link_libraries(test_runner PRIVATE + ${Protobuf_LIBRARIES} + ${GRPC_LIBRARIES} + ${GRPCPP_LIBRARIES} + Threads::Threads +) + +set_target_properties(test_runner PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +target_compile_definitions(test_runner PRIVATE MAVLINK_DIALECT="${MAVLINK_DIALECT}") +target_compile_options(test_runner PRIVATE -Wall -Wextra -Werror) + +if (CMAKE_BUILD_TYPE STREQUAL "Debug") + target_compile_options(test_runner PRIVATE -O0 -g) +else() + target_compile_options(test_runner PRIVATE -O3) +endif() + +add_test(NAME bridge_tests COMMAND test_runner) \ No newline at end of file diff --git a/bridge/test/test_converter.cc b/bridge/test/test_converter.cc new file mode 100644 index 0000000..12eed80 --- /dev/null +++ b/bridge/test/test_converter.cc @@ -0,0 +1,96 @@ +#include "mavlink/MessageConverter.h" +#include +#include +#include +#include + +#define TEST_ASSERT(cond) \ + do { \ + if (!(cond)) { \ + std::cerr << "Assertion failed: " #cond " at " << __FILE__ << ":" << __LINE__ << std::endl; \ + std::abort(); \ + } \ + } while (0) + +using namespace mavlink2grpc; + +void test_message_converter_heartbeat() { + std::cout << "Running test_message_converter_heartbeat..." << std::endl; + + // Locate the message definition file + std::string xml_path = ""; + std::vector paths = { + "third_party/mavlink/message_definitions/v1.0/common.xml", + "../third_party/mavlink/message_definitions/v1.0/common.xml", + "mavlink/message_definitions/v1.0/common.xml", + "../mavlink/message_definitions/v1.0/common.xml" + }; + + for (const auto& p : paths) { + if (access(p.c_str(), F_OK) != -1) { + xml_path = p; + break; + } + } + + if (xml_path.empty()) { + std::cerr << "Warning: Could not find common.xml definition. Skipping converter tests." << std::endl; + return; + } + + // Load MessageSet + mav::MessageSet message_set(xml_path); + + // 1. Create a mav::Message for HEARTBEAT + mav::Message mav_msg = message_set.create("HEARTBEAT"); + TEST_ASSERT(mav_msg.name() == "HEARTBEAT"); + TEST_ASSERT(mav_msg.id() == 0); + + // Set header partner ID (represents system ID and component ID) + // libmav sets source system/component via setFromConnectionPartner + // Let's set some fields on the message + mav_msg.set("type", static_cast(2)); // MAV_TYPE_QUADROTOR + mav_msg.set("autopilot", static_cast(3)); // MAV_AUTOPILOT_ARDUPILOTMEGA + mav_msg.set("base_mode", static_cast(81)); + mav_msg.set("custom_mode", static_cast(12345)); + mav_msg.set("system_status", static_cast(3)); // MAV_STATE_STANDBY + mav_msg.set("mavlink_version", static_cast(3)); + + // 2. Convert to Protobuf + // We can pass system_id = 42 and component_id = 191 by simulating a connection partner if needed, + // but let's check what default to_proto does or if it gets them from the message source. + auto proto_opt = MessageConverter::to_proto(mav_msg, 123456789ULL); + TEST_ASSERT(proto_opt.has_value()); + + auto proto_msg = proto_opt.value(); + TEST_ASSERT(proto_msg.message_id() == 0); + TEST_ASSERT(proto_msg.timestamp_usec() == 123456789ULL); + TEST_ASSERT(proto_msg.has_heartbeat()); + + const auto& heartbeat = proto_msg.heartbeat(); + TEST_ASSERT(heartbeat.type() == 2); + TEST_ASSERT(heartbeat.autopilot() == 3); + TEST_ASSERT(heartbeat.base_mode() == 81); + TEST_ASSERT(heartbeat.custom_mode() == 12345); + TEST_ASSERT(heartbeat.system_status() == 3); + + // 3. Convert back from Protobuf to mav::Message + auto back_opt = MessageConverter::from_proto(proto_msg, message_set); + TEST_ASSERT(back_opt.has_value()); + + auto back_msg = back_opt.value(); + TEST_ASSERT(back_msg.name() == "HEARTBEAT"); + TEST_ASSERT(back_msg.id() == 0); + + TEST_ASSERT(back_msg.get("type") == 2); + TEST_ASSERT(back_msg.get("autopilot") == 3); + TEST_ASSERT(back_msg.get("base_mode") == 81); + TEST_ASSERT(back_msg.get("custom_mode") == 12345); + TEST_ASSERT(back_msg.get("system_status") == 3); + + std::cout << "test_message_converter_heartbeat passed!" << std::endl; +} + +void run_converter_tests() { + test_message_converter_heartbeat(); +} diff --git a/bridge/test/test_router.cc b/bridge/test/test_router.cc new file mode 100644 index 0000000..678170c --- /dev/null +++ b/bridge/test/test_router.cc @@ -0,0 +1,139 @@ +#include "service/Router.h" +#include +#include + +#define TEST_ASSERT(cond) \ + do { \ + if (!(cond)) { \ + std::cerr << "Assertion failed: " #cond " at " << __FILE__ << ":" << __LINE__ << std::endl; \ + std::abort(); \ + } \ + } while (0) + +using namespace mavlink2grpc; + +void test_router_basic() { + std::cout << "Running test_router_basic..." << std::endl; + Router router; + + TEST_ASSERT(router.subscription_count() == 0); + + // Subscribe client 1 with no filters (system_id=0, component_id=0) + mavlink::StreamFilter filter1; + filter1.set_system_id(0); + filter1.set_component_id(0); + + size_t client1_received = 0; + uint64_t sub1 = router.subscribe(filter1, [&](const mavlink::MavlinkMessage&) { + client1_received++; + return true; + }); + + TEST_ASSERT(sub1 == 1); + TEST_ASSERT(router.subscription_count() == 1); + + // Send a message + mavlink::MavlinkMessage msg1; + msg1.set_system_id(1); + msg1.set_component_id(1); + msg1.set_message_id(0); // HEARTBEAT + + size_t routed = router.route_message(msg1); + TEST_ASSERT(routed == 1); + TEST_ASSERT(client1_received == 1); + + // Unsubscribe client 1 + bool unsub_ok = router.unsubscribe(sub1); + TEST_ASSERT(unsub_ok); + TEST_ASSERT(router.subscription_count() == 0); + + routed = router.route_message(msg1); + TEST_ASSERT(routed == 0); + TEST_ASSERT(client1_received == 1); // should remain 1 + + std::cout << "test_router_basic passed!" << std::endl; +} + +void test_router_filters() { + std::cout << "Running test_router_filters..." << std::endl; + Router router; + + // Filter for system 1, component 1, message 30 (ATTITUDE) only + mavlink::StreamFilter filter; + filter.set_system_id(1); + filter.set_component_id(1); + filter.add_message_ids(30); + + size_t received = 0; + router.subscribe(filter, [&](const mavlink::MavlinkMessage&) { + received++; + return true; + }); + + // Message 1: Matches all + mavlink::MavlinkMessage msg1; + msg1.set_system_id(1); + msg1.set_component_id(1); + msg1.set_message_id(30); + router.route_message(msg1); + TEST_ASSERT(received == 1); + + // Message 2: Different system ID (2) + mavlink::MavlinkMessage msg2; + msg2.set_system_id(2); + msg2.set_component_id(1); + msg2.set_message_id(30); + router.route_message(msg2); + TEST_ASSERT(received == 1); // shouldn't increase + + // Message 3: Different component ID (2) + mavlink::MavlinkMessage msg3; + msg3.set_system_id(1); + msg3.set_component_id(2); + msg3.set_message_id(30); + router.route_message(msg3); + TEST_ASSERT(received == 1); // shouldn't increase + + // Message 4: Different message ID (0 - HEARTBEAT) + mavlink::MavlinkMessage msg4; + msg4.set_system_id(1); + msg4.set_component_id(1); + msg4.set_message_id(0); + router.route_message(msg4); + TEST_ASSERT(received == 1); // shouldn't increase + + std::cout << "test_router_filters passed!" << std::endl; +} + +void test_router_cleanup() { + std::cout << "Running test_router_cleanup..." << std::endl; + Router router; + + mavlink::StreamFilter filter; + + // Client whose write fails (simulates disconnected client) + router.subscribe(filter, [](const mavlink::MavlinkMessage&) { + return false; // write failed + }); + + TEST_ASSERT(router.subscription_count() == 1); + + // Route a message - this will trigger write failure and mark inactive + mavlink::MavlinkMessage msg; + size_t routed = router.route_message(msg); + + TEST_ASSERT(routed == 0); + TEST_ASSERT(router.subscription_count() == 0); // Active subscription count should drop to 0 + + // Cleanup should remove it from internal list + size_t cleaned = router.cleanup_inactive(); + TEST_ASSERT(cleaned == 1); + + std::cout << "test_router_cleanup passed!" << std::endl; +} + +void run_router_tests() { + test_router_basic(); + test_router_filters(); + test_router_cleanup(); +} diff --git a/bridge/test/test_runner.cc b/bridge/test/test_runner.cc new file mode 100644 index 0000000..d007b78 --- /dev/null +++ b/bridge/test/test_runner.cc @@ -0,0 +1,19 @@ +#include + +extern void run_router_tests(); +extern void run_converter_tests(); + +int main() { + std::cout << "========================================" << std::endl; + std::cout << "Starting C++ Unit Tests" << std::endl; + std::cout << "========================================" << std::endl; + + run_router_tests(); + run_converter_tests(); + + std::cout << "========================================" << std::endl; + std::cout << "All C++ Unit Tests Passed Successfully!" << std::endl; + std::cout << "========================================" << std::endl; + + return 0; +} diff --git a/generator/test/test_generator.py b/generator/test/test_generator.py index 28e55ed..0339d8c 100644 --- a/generator/test/test_generator.py +++ b/generator/test/test_generator.py @@ -37,7 +37,7 @@ def parse_all_dialects(parser: MAVLinkParser, dialect_names: list) -> dict: for name in dialect_names: try: - dialect = parser.parse_file(f"{name}.xml") + dialect = parser.get_flattened_dialect(name) dialects[name] = dialect progress.update(task, advance=1) except Exception as e: @@ -132,7 +132,9 @@ def main(): console.print(Panel.fit("Core MAVLink Dialects Test (minimal, standard, common)", style="bold blue")) # Paths - xml_dir = Path(__file__).parent.parent.parent / "mavlink" / "message_definitions" / "v1.0" + xml_dir = Path(__file__).parent.parent.parent / "third_party" / "mavlink" / "message_definitions" / "v1.0" + if not xml_dir.exists(): + xml_dir = Path(__file__).parent.parent.parent / "mavlink" / "message_definitions" / "v1.0" template_dir = Path(__file__).parent.parent / "templates" output_dir = Path(__file__).parent.parent.parent / "proto" diff --git a/generator/test/test_parser.py b/generator/test/test_parser.py index 4e97b1a..5fad924 100644 --- a/generator/test/test_parser.py +++ b/generator/test/test_parser.py @@ -18,8 +18,10 @@ def main(): """Test the parser with real MAVLink XML files.""" - # Path to MAVLink definitions (go up to project root, then to mavlink) - xml_dir = Path(__file__).parent.parent.parent / "mavlink" / "message_definitions" / "v1.0" + # Path to MAVLink definitions (go up to project root, then to third_party/mavlink or mavlink) + xml_dir = Path(__file__).parent.parent.parent / "third_party" / "mavlink" / "message_definitions" / "v1.0" + if not xml_dir.exists(): + xml_dir = Path(__file__).parent.parent.parent / "mavlink" / "message_definitions" / "v1.0" if not xml_dir.exists(): console.print(f"[red]Error: MAVLink definitions not found at {xml_dir}[/red]") diff --git a/setup.sh b/setup.sh index c47cd3c..9249399 100755 --- a/setup.sh +++ b/setup.sh @@ -17,7 +17,8 @@ sudo apt-get install -y \ python3-pip \ libgrpc++-dev \ libprotobuf-dev \ - protobuf-compiler-grpc + protobuf-compiler-grpc \ + nlohmann-json3-dev # Ensure git submodules are initialized and updated echo "-- Initializing and updating submodules..."