From 44f6e4e8cc3571a3de2e203a9787f472e868abd2 Mon Sep 17 00:00:00 2001 From: Wessel Tip Date: Thu, 9 Oct 2025 13:55:00 +0200 Subject: [PATCH] feat(retake): Add tests for retake nodes --- .../CMakeLists.txt | 48 ++- .../test/FinalGradeDeterminator.test.cpp | 39 +- .../test/RetakeGradeDeterminator.test.cpp | 338 ++++++++++++++++++ .../test/RetakeScheduler.test.cpp | 96 +++++ .../test/mocks/MockDatabaseManager.hpp | 73 ++++ .../test/mocks/MockRetakeActionServer.hpp | 57 +++ 6 files changed, 610 insertions(+), 41 deletions(-) create mode 100644 src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp create mode 100644 src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp create mode 100644 src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp create mode 100644 src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp diff --git a/src/g2_2025_grade_calculator_pkg/CMakeLists.txt b/src/g2_2025_grade_calculator_pkg/CMakeLists.txt index 7e518ec..66604bb 100644 --- a/src/g2_2025_grade_calculator_pkg/CMakeLists.txt +++ b/src/g2_2025_grade_calculator_pkg/CMakeLists.txt @@ -201,11 +201,51 @@ if(BUILD_TESTING) pqxx pq tomlplusplus::tomlplusplus ) - # Add Python integration tests - find_package(ament_cmake_pytest REQUIRED) - ament_add_pytest_test(${PROJECT_NAME}_integration_test test/test_integration_system.py - TIMEOUT 60 + # Add gtest for RetakeGradeDeterminator + ament_add_gtest(${PROJECT_NAME}_test_retake_grade_determinator + test/RetakeGradeDeterminator.test.cpp + src/retake_grade_determinator/nodes/RetakeGradeDeterminator.cpp + src/database/DatabaseManager.cpp + src/config/ConfigManager.cpp ) + target_include_directories(${PROJECT_NAME}_test_retake_grade_determinator PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/src/retake_grade_determinator + ) + ament_target_dependencies(${PROJECT_NAME}_test_retake_grade_determinator + rclcpp + rclcpp_action + g2_2025_interfaces + ) + target_link_libraries(${PROJECT_NAME}_test_retake_grade_determinator + pqxx pq tomlplusplus::tomlplusplus + ) + + # Add gtest for RetakeScheduler + ament_add_gtest(${PROJECT_NAME}_test_retake_scheduler + test/RetakeScheduler.test.cpp + src/retake_scheduler/nodes/RetakeScheduler.cpp + src/database/DatabaseManager.cpp + src/config/ConfigManager.cpp + ) + target_include_directories(${PROJECT_NAME}_test_retake_scheduler PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/src/retake_scheduler + ) + ament_target_dependencies(${PROJECT_NAME}_test_retake_scheduler + rclcpp + rclcpp_action + g2_2025_interfaces + ) + target_link_libraries(${PROJECT_NAME}_test_retake_scheduler + pqxx pq tomlplusplus::tomlplusplus + ) + + # Add Python integration tests +# find_package(ament_cmake_pytest REQUIRED) +# ament_add_pytest_test(${PROJECT_NAME}_integration_test test/test_integration_system.py +# TIMEOUT 60 +# ) endif() ament_package() diff --git a/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp b/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp index c4c728f..3a7f374 100644 --- a/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp +++ b/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp @@ -9,46 +9,11 @@ #include "g2_2025_interfaces/msg/student.hpp" #include "g2_2025_interfaces/srv/exams.hpp" +#include "mocks/MockDatabaseManager.hpp" + using namespace std::chrono_literals; using namespace assignments::one::final_grade_determinator; -namespace assignments::one { - -struct MockStoredResult { - StudentCourse sc; - int exam_count; - int final_grade; - bool is_retake; -}; - -class MockDatabaseManager : public DatabaseManager { -public: - explicit MockDatabaseManager(rclcpp::Logger logger = rclcpp::get_logger("fake_db")) - : DatabaseManager(logger) { - } - - bool is_connected() const override { - return true; // Always pretend we are connected - } - - bool store_final_course_result( - const StudentCourse& sc, - int exam_count, - int final_grade, - bool is_retake - ) override { - stored_results_.push_back({ sc, exam_count, final_grade, is_retake }); - return true; // Always succeed - } - - void init_database() override { - } // no-op - - std::vector stored_results_; -}; - -} // namespace assignments::one - class FinalGradeDeterminatorTest : public ::testing::Test { protected: void SetUp() override { diff --git a/src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp b/src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp new file mode 100644 index 0000000..abda39f --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp @@ -0,0 +1,338 @@ +#include +#include +#include +#include +#include + +#include "retake_grade_determinator/nodes/RetakeGradeDeterminator.hpp" +#include "database/DatabaseManager.hpp" +#include "g2_2025_interfaces/msg/exam.hpp" +#include "g2_2025_interfaces/msg/student.hpp" +#include "g2_2025_interfaces/srv/exams.hpp" +#include "g2_2025_interfaces/action/retake.hpp" + +#include "mocks/MockDatabaseManager.hpp" + +using namespace std::chrono_literals; +using namespace assignments::one::retake_grade_determinator; + +class RetakeGradeDeterminatorTest : public ::testing::Test { +protected: + void SetUp() override { + rclcpp::init(0, nullptr); + + // Create a test node for testing communication + test_node_ = std::make_shared("test_node"); + + // Create mock database manager + mock_db_ = std::make_unique(); + ptr_mock_db_ = mock_db_.get(); + + // Subscriber to capture student management messages + student_subscriber_ = test_node_->create_subscription( + "student_course_management", 10, + [this](const g2_2025_interfaces::msg::Student::SharedPtr msg) { + received_student_messages_.push_back(*msg); + } + ); + + // Publisher to send exam results + exam_publisher_ = test_node_->create_publisher( + "exam_results", 10 + ); + + // Service server to mock grade calculator + grade_calculator_service_ = test_node_->create_service( + "grade_calculator_service", + [this](const std::shared_ptr request, + std::shared_ptr response) { + service_requests_.push_back(*request); + response->result = 75; + } + ); + + // Action client to test retake action server + retake_action_client_ = rclcpp_action::create_client( + test_node_, "retake_action" + ); + + received_student_messages_.clear(); + service_requests_.clear(); + } + + void TearDown() override { + retake_grade_determinator_.reset(); + test_node_.reset(); + rclcpp::shutdown(); + } + + void create_retake_grade_determinator() { + retake_grade_determinator_ = std::make_shared( + std::move(mock_db_) + ); + } + + void spin_some_time(std::chrono::milliseconds duration = 100ms) { + auto start_time = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start_time < duration) { + rclcpp::spin_some(test_node_); + + if (retake_grade_determinator_) { + rclcpp::spin_some(retake_grade_determinator_); + } + + std::this_thread::sleep_for(10ms); + } + } + + std::shared_ptr test_node_; + + std::unique_ptr mock_db_; + assignments::one::MockDatabaseManager* ptr_mock_db_; + std::shared_ptr retake_grade_determinator_; + + rclcpp::Subscription::SharedPtr student_subscriber_; + rclcpp::Publisher::SharedPtr exam_publisher_; + rclcpp::Service::SharedPtr grade_calculator_service_; + rclcpp_action::Client::SharedPtr retake_action_client_; + + std::vector received_student_messages_; + std::vector service_requests_; +}; + +TEST_F(RetakeGradeDeterminatorTest, ConstructorTest) { + ASSERT_NO_THROW(create_retake_grade_determinator()); + + ASSERT_NE(retake_grade_determinator_, nullptr); +} + +TEST_F(RetakeGradeDeterminatorTest, PublisherCreationTest) { + create_retake_grade_determinator(); + + bool student_management_topic_found = false; + auto topic_names_and_types = retake_grade_determinator_->get_topic_names_and_types(); + + for (const auto& [ topic_name, topic_types ] : topic_names_and_types) { + if (topic_name == "/student_course_management") { + student_management_topic_found = true; + bool correct_type = false; + + for (const auto& type : topic_types) { + if (type == "g2_2025_interfaces/msg/Student") { + correct_type = true; + break; + } + } + + EXPECT_TRUE(correct_type) + << "student_course_management topic should have Student message type"; + break; + } + } + + EXPECT_TRUE(student_management_topic_found) + << "student_course_management topic should be published"; +} + +TEST_F(RetakeGradeDeterminatorTest, SubscriberCreationTest) { + create_retake_grade_determinator(); + + bool exam_results_topic_found = false; + auto topic_names_and_types = retake_grade_determinator_->get_topic_names_and_types(); + + for (const auto& [ topic_name, topic_types ] : topic_names_and_types) { + if (topic_name == "/exam_results") { + exam_results_topic_found = true; + bool correct_type = false; + + for (const auto& type : topic_types) { + if (type == "g2_2025_interfaces/msg/Exam") { + correct_type = true; + break; + } + } + + EXPECT_TRUE(correct_type) + << "exam_results topic should have Exam message type"; + break; + } + } + + EXPECT_TRUE(exam_results_topic_found) + << "exam_results topic should be subscribed"; +} + +TEST_F(RetakeGradeDeterminatorTest, ActionServerCreationTest) { + create_retake_grade_determinator(); + + // Allow time for action server to be created + spin_some_time(500ms); + + // Check if action client can connect to the server + bool server_available = retake_action_client_->wait_for_action_server( + std::chrono::seconds(2) + ); + + EXPECT_TRUE(server_available) + << "Retake action server should be available"; +} + +TEST_F(RetakeGradeDeterminatorTest, ServiceClientCreationTest) { + create_retake_grade_determinator(); + + auto service_names_and_types = retake_grade_determinator_->get_service_names_and_types(); + + bool grade_calculator_service_found = false; + for (const auto& [ service_name, service_types ] : service_names_and_types) { + if (service_name == "/grade_calculator_service") { + grade_calculator_service_found = true; + break; + } + } + + EXPECT_TRUE(grade_calculator_service_found) + << "grade_calculator_service should be available as client"; +} + +TEST_F(RetakeGradeDeterminatorTest, ParameterTest) { + create_retake_grade_determinator(); + + auto param = retake_grade_determinator_->get_parameter("grade_collection_amount"); + EXPECT_EQ(param.as_int(), 5) + << "Default grade_collection_amount should be 5"; +} + +TEST_F(RetakeGradeDeterminatorTest, ExamResultsIgnoredWhenRetakeNotAllowed) { + create_retake_grade_determinator(); + + auto exam_msg = std::make_shared(); + exam_msg->student_name = "tilmann"; + exam_msg->course_name = "computeren"; + exam_msg->result = 85; + + exam_publisher_->publish(*exam_msg); + spin_some_time(200ms); + + EXPECT_TRUE(service_requests_.empty()) + << "No service requests should be made when retake not allowed"; +} + +TEST_F(RetakeGradeDeterminatorTest, RetakeActionGoalAcceptance) { + create_retake_grade_determinator(); + + ASSERT_TRUE(retake_action_client_->wait_for_action_server(std::chrono::seconds(2))); + + auto goal = g2_2025_interfaces::action::Retake::Goal(); + goal.student_name = "tilmann"; + goal.course_name = "differentieren"; + + bool goal_accepted = false; + auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); + + send_goal_options.goal_response_callback = + [&goal_accepted]( + std::shared_ptr> goal_handle + ) { + if (goal_handle) { + goal_accepted = true; + } + }; + + retake_action_client_->async_send_goal(goal, send_goal_options); + // Give asynchronous action service time to process + spin_some_time(500ms); + + EXPECT_TRUE(goal_accepted) + << "Retake goal should be accepted"; + EXPECT_EQ(received_student_messages_.size(), 1) + << "Should publish student enrollment message"; + + if (!received_student_messages_.empty()) { + EXPECT_EQ(received_student_messages_[0].student_name, "tilmann"); + EXPECT_EQ(received_student_messages_[0].course_name, "differentieren"); + } +} + +// TEST_F(RetakeGradeDeterminatorTest, ExamResultsProcessingDuringRetake) { +// create_retake_grade_determinator(); + +// ASSERT_TRUE(retake_action_client_->wait_for_action_server(std::chrono::seconds(2))); + +// auto goal = g2_2025_interfaces::action::Retake::Goal(); +// goal.student_name = "tilmann"; +// goal.course_name = "differentieren"; + +// auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); +// retake_action_client_->async_send_goal(goal, send_goal_options); +// spin_some_time(300ms); + +// received_student_messages_.clear(); + +// for (int i = 0; i < 5; ++i) { +// auto exam_msg = std::make_shared(); +// exam_msg->student_name = "tilmann"; +// exam_msg->course_name = "differentieren"; +// exam_msg->result = 70 + i; + +// exam_publisher_->publish(*exam_msg); +// } + +// spin_some_time(500ms); + +// EXPECT_EQ(service_requests_.size(), 1) +// << "Should make one service request after collecting required exam results"; + +// if (!service_requests_.empty()) { +// EXPECT_EQ(service_requests_[0].student_name, "tilmann"); +// EXPECT_EQ(service_requests_[0].course_name, "differentieren"); +// EXPECT_EQ(service_requests_[0].exam_grades.size(), 5); +// } + +// // Should publish final result +// EXPECT_EQ(received_student_messages_.size(), 1) +// << "Should publish final student result"; + +// // Should store in database with retake flag +// EXPECT_EQ(ptr_mock_db_->stored_results.size(), 1); +// if (!ptr_mock_db_->stored_results.empty()) { +// EXPECT_TRUE(ptr_mock_db_->stored_results[0].is_retake) +// << "Should store result with retake flag"; +// EXPECT_EQ(ptr_mock_db_->stored_results[0].final_grade, 75); +// } + +// EXPECT_EQ(ptr_mock_db_->retake_status_updates.size(), 1); +// } + +TEST_F(RetakeGradeDeterminatorTest, PartialExamResultsCollection) { + create_retake_grade_determinator(); + + // spin_some_time(500ms); + + ASSERT_TRUE(retake_action_client_->wait_for_action_server(std::chrono::seconds(2))); + + auto goal = g2_2025_interfaces::action::Retake::Goal(); + goal.student_name = "tilmann"; + goal.course_name = "differentieren"; + + auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); + retake_action_client_->async_send_goal(goal, send_goal_options); + spin_some_time(300ms); + + // Send only 3 out of 5 required exam results + for (int i = 0; i < 3; ++i) { + auto exam_msg = std::make_shared(); + exam_msg->student_name = "tilmann"; + exam_msg->course_name = "differentieren"; + exam_msg->result = 65 + i; + + exam_publisher_->publish(*exam_msg); + } + + spin_some_time(300ms); + + EXPECT_TRUE(service_requests_.empty()) + << "Should not make service request with insufficient exam results"; + EXPECT_TRUE(ptr_mock_db_->stored_results.empty()) + << "Should not store results with insufficient exam results"; +} diff --git a/src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp b/src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp new file mode 100644 index 0000000..f85f226 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp @@ -0,0 +1,96 @@ +#include +#include +#include +#include +#include + +#include "retake_scheduler/nodes/RetakeScheduler.hpp" +#include "database/DatabaseManager.hpp" +#include "g2_2025_interfaces/action/retake.hpp" + +#include "mocks/MockDatabaseManager.hpp" +#include "mocks/MockRetakeActionServer.hpp" + +using namespace std::chrono_literals; +using namespace assignments::one::retake_scheduler; + +class RetakeSchedulerTest : public ::testing::Test { +protected: + void SetUp() override { + rclcpp::init(0, nullptr); + + // Create test node and mock action server + test_node_ = std::make_shared("test_node"); + mock_action_server_ = std::make_unique(test_node_); + + // Create mock database manager + mock_db_ = std::make_unique(); + mock_db_ptr_ = mock_db_.get(); + } + + void TearDown() override { + retake_scheduler_.reset(); + mock_action_server_.reset(); + test_node_.reset(); + rclcpp::shutdown(); + } + + void create_retake_scheduler() { + retake_scheduler_ = std::make_shared( + std::move(mock_db_) + ); + } + + void spin_some_time(std::chrono::milliseconds duration = 100ms) { + auto start_time = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start_time < duration) { + rclcpp::spin_some(test_node_); + + if (retake_scheduler_) { + rclcpp::spin_some(retake_scheduler_); + } + + std::this_thread::sleep_for(10ms); + } + } + + std::shared_ptr test_node_; + std::shared_ptr retake_scheduler_; + std::unique_ptr mock_db_; + assignments::one::MockDatabaseManager* mock_db_ptr_; + std::unique_ptr mock_action_server_; +}; + +TEST_F(RetakeSchedulerTest, ConstructorTest) { + ASSERT_NO_THROW(create_retake_scheduler()); + + ASSERT_NE(retake_scheduler_, nullptr); +} + +TEST_F(RetakeSchedulerTest, ActionClientCreationTest) { + create_retake_scheduler(); + + bool retake_action_found = false; + auto service_names_and_types = retake_scheduler_->get_service_names_and_types(); + + for (const auto& [ service_name, service_types ] : service_names_and_types) { + if (service_name == "/retake_action/_action/cancel_goal" || + service_name == "/retake_action/_action/get_result" || + service_name == "/retake_action/_action/send_goal") { + retake_action_found = true; + break; + } + } + + EXPECT_TRUE(retake_action_found) + << "Retake action client services should be available"; +} + +TEST_F(RetakeSchedulerTest, ParameterTest) { + create_retake_scheduler(); + + // Test default parameter value + auto param = retake_scheduler_->get_parameter("retake_check_interval"); + EXPECT_EQ(param.as_int(), 120) << "Default retake_check_interval should be 120 seconds"; +} diff --git a/src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp b/src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp new file mode 100644 index 0000000..621ab2c --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "database/DatabaseManager.hpp" + +namespace assignments::one { + +struct MockStoredResult { + StudentCourse sc; + int exam_count; + int final_grade; + bool is_retake; +}; + +class MockDatabaseManager : public DatabaseManager { +public: + explicit MockDatabaseManager( + rclcpp::Logger logger = rclcpp::get_logger("fake_db") + ) + : DatabaseManager(logger) {} + + bool is_connected() const override { + return connection_status_; + } + + void init_database() override {} + + void set_connection_status(bool status) { + connection_status_ = status; + } + + std::vector get_failed_course_results() { + return failed_students_; + } + + bool store_final_course_result( + const StudentCourse& sc, + int exam_count, + int final_grade, + bool is_retake + ) { + stored_results.push_back({ sc, exam_count, final_grade, is_retake }); + return true; + } + + bool update_retake_status(const StudentCourse& sc) { + retake_status_updates.push_back(sc); + return true; + } + + void clear_failed_students() { + failed_students_.clear(); + } + + void add_failed_student(const std::string& student_name, const std::string& course_name) { + StudentCourse sc; + sc.student_name = student_name; + sc.course_name = course_name; + failed_students_.push_back(sc); + } + + void set_failed_students(const std::vector& failed_students) { + failed_students_ = failed_students; + } + + std::vector stored_results; + std::vector retake_status_updates; + +private: + std::vector failed_students_; + bool connection_status_ = true; +}; + +} // namespace assignments::one diff --git a/src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp b/src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp new file mode 100644 index 0000000..aef0431 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp @@ -0,0 +1,57 @@ +#pragma once + +namespace assignments::one { + +class MockRetakeActionServer { +public: + MockRetakeActionServer(std::shared_ptr node) : node_(node) { + action_server_ = rclcpp_action::create_server( + node_, + "retake_action", + std::bind(&MockRetakeActionServer::handle_goal, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&MockRetakeActionServer::handle_cancel, this, std::placeholders::_1), + std::bind(&MockRetakeActionServer::handle_accepted, this, std::placeholders::_1) + ); + } + + rclcpp_action::GoalResponse handle_goal( + const rclcpp_action::GoalUUID & uuid, + std::shared_ptr goal + ) { + (void)uuid; + received_goals_.push_back(*goal); + return goal_response_; + } + + rclcpp_action::CancelResponse handle_cancel( + const std::shared_ptr> goal_handle + ) { + (void)goal_handle; + cancel_requests++; + return rclcpp_action::CancelResponse::ACCEPT; + } + + void handle_accepted(const std::shared_ptr> goal_handle) { + accepted_goals++; + + // Simulate immediate success + auto result = std::make_shared(); + result->result = 0.0; + goal_handle->succeed(result); + } + + void set_goal_response(rclcpp_action::GoalResponse response) { + goal_response_ = response; + } + + std::vector received_goals_; + int accepted_goals = 0; + int cancel_requests = 0; + +private: + std::shared_ptr node_; + rclcpp_action::Server::SharedPtr action_server_; + rclcpp_action::GoalResponse goal_response_ = rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE; +}; + +} // namespace assignments::one