diff --git a/.gitignore b/.gitignore index c32553f..216cdd7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,7 @@ qtcreator-* COLCON_IGNORE AMENT_IGNORE +.vscode + # End of https://www.toptal.com/developers/gitignore/api/ros2 diff --git a/doc/nodes/FinalGradeDeterminator.md b/doc/nodes/FinalGradeDeterminator.md new file mode 100644 index 0000000..0c2f3d3 --- /dev/null +++ b/doc/nodes/FinalGradeDeterminator.md @@ -0,0 +1,35 @@ +# FinalGradeDeterminator (`assignments::one::final_grade_determinator`) + +## Overview +The `FinalGradeDeterminator` node collects exam results for student-course combinations, triggers grade calculation when enough results are gathered, and stores final grades in the database. It interacts with ROS2 publishers, subscribers, and service clients to manage the grading workflow. + +#### Implementation Details + +**Constructor** +```cpp +FinalGradeDeterminator() +``` +- Initializes ROS2 node with name `final_grade_determinator` +- Declares and retrieves `grade_collection_amount` parameter +- Sets up `DatabaseManager` +- Creates publisher for student course management +- Subscribes to exam results topic +- Initializes service client for grade calculation + +**Core Functions** + +**`void exam_results_callback(const g2_2025_interfaces::msg::Exam::SharedPtr msg)`** +- Updates internal map with received exam result for student-course combo +- Checks if enough results have been collected +- Triggers grade calculation request when threshold is met + +**`void grade_calculator_request(StudentCourse combo)`** +- Waits for grade calculator service to be available +- Sends async request with collected exam grades for the student-course combination +- Uses callback to handle service response + +**`void grade_calculator_response(rclcpp::Client::SharedFuture future, StudentCourse studentCourseCombo)`** +- Verifies database connection +- Publishes final student message to ROS2 topic +- Logs final grade information +- Stores final course result in database diff --git a/doc/nodes/GradeCalculator.md b/doc/nodes/GradeCalculator.md new file mode 100644 index 0000000..0e90fd5 --- /dev/null +++ b/doc/nodes/GradeCalculator.md @@ -0,0 +1,25 @@ +# GradeCalculator (`assignments::one::grade_calculator::GradeCalculator`) + +## Overview +The `GradeCalculator` node provides a ROS2 service for calculating student exam grades. It processes exam scores, applies custom logic for specific student names, and ensures grade results are within valid bounds. + +#### Implementation Details + +**Constructor** +```cpp +GradeCalculator() +``` +- Initializes ROS2 node with name `grade_calculator` +- Creates a ROS2 service server for `grade_calculator_service` +- Binds the service callback to handle grade calculation requests +- Logs service startup + +**Core Functions** + +**`void grade_calculator_callback(const Exams::Request::SharedPtr request, const Exams::Response::SharedPtr response)`** +- Checks if exam grades are provided +- Calculates the total and average of exam grades +- Converts student name to lowercase for comparison +- Adds a bonus of 10 points if the student name is "wessel" +- Ensures the final grade is clamped between 10 and 100 +- Sends the calculated grade back through the service response diff --git a/src/g2_2025_grade_calculator_pkg/CMakeLists.txt b/src/g2_2025_grade_calculator_pkg/CMakeLists.txt index 7eef918..cb4a3e2 100644 --- a/src/g2_2025_grade_calculator_pkg/CMakeLists.txt +++ b/src/g2_2025_grade_calculator_pkg/CMakeLists.txt @@ -127,6 +127,39 @@ if(BUILD_TESTING) target_link_libraries(${PROJECT_NAME}_test_exam_result_generator pqxx pq tomlplusplus::tomlplusplus ) + + # Add gtest for GradeCalculator + ament_add_gtest(${PROJECT_NAME}_test_grade_calculator + test/GradeCalculator.test.cpp + src/grade_calculator/nodes/GradeCalculator.cpp + ) + target_include_directories(${PROJECT_NAME}_test_grade_calculator PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/src/grade_calculator + ) + ament_target_dependencies(${PROJECT_NAME}_test_grade_calculator + rclcpp + g2_2025_interfaces + ) + + # Add gtest for FinalGradeDeterminator + ament_add_gtest(${PROJECT_NAME}_test_final_grade_determinator + test/FinalGradeDeterminator.test.cpp + src/final_grade_determinator/nodes/FinalGradeDeterminator.cpp + src/database/DatabaseManager.cpp + src/config/ConfigManager.cpp + ) + target_include_directories(${PROJECT_NAME}_test_final_grade_determinator PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/src/final_grade_determinator + ) + ament_target_dependencies(${PROJECT_NAME}_test_final_grade_determinator + rclcpp + g2_2025_interfaces + ) + target_link_libraries(${PROJECT_NAME}_test_final_grade_determinator + pqxx pq tomlplusplus::tomlplusplus + ) endif() ament_package() diff --git a/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.hpp b/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.hpp index 1d3b2d2..ac73413 100644 --- a/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.hpp +++ b/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.hpp @@ -24,10 +24,10 @@ public: ~DatabaseManager() = default; bool connect(const std::string& connection_string); - bool is_connected() const; + virtual bool is_connected() const; // Table operations - void init_database(); + virtual void init_database(); void create_tables(); void insert_sample_data(); @@ -35,7 +35,7 @@ public: std::vector queue_pending_combinations(); bool store_exam_result(const std::string& student_name, const std::string& course_name, int grade); bool enroll_student_into_course(const StudentCourse& sc); - bool store_final_course_result(const StudentCourse& sc, int exam_count, int final_grade); + virtual bool store_final_course_result(const StudentCourse& sc, int exam_count, int final_grade); int get_final_course_grade(const StudentCourse& sc); diff --git a/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.cpp b/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.cpp index bcf753d..7bebb39 100644 --- a/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.cpp +++ b/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.cpp @@ -2,12 +2,16 @@ namespace assignments::one::final_grade_determinator { -FinalGradeDeterminator::FinalGradeDeterminator() : Node("final_grade_determinator") { +FinalGradeDeterminator::FinalGradeDeterminator(std::unique_ptr db_manager) : Node("final_grade_determinator") { this->declare_parameter("grade_collection_amount", 5); grade_collection_amount_ = this->get_parameter("grade_collection_amount").as_int(); - db_manager_ = std::make_unique(this->get_logger()); - + // Make db_manager optional for testing purposes + if (db_manager) { + db_manager_ = std::move(db_manager); + } else { + db_manager_ = std::make_unique(this->get_logger()); + } // Create publisher for exam results student_publisher_ = this->create_publisher( "student_course_management", 10 diff --git a/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.hpp b/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.hpp index 8703bc7..b9db482 100644 --- a/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.hpp +++ b/src/g2_2025_grade_calculator_pkg/src/final_grade_determinator/nodes/FinalGradeDeterminator.hpp @@ -18,7 +18,7 @@ namespace assignments::one::final_grade_determinator { class FinalGradeDeterminator : public rclcpp::Node { public: - FinalGradeDeterminator(); + FinalGradeDeterminator(std::unique_ptr db_manager = nullptr); private: rclcpp::Subscription::SharedPtr exam_subscriber_; rclcpp::Publisher::SharedPtr student_publisher_; diff --git a/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp b/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp new file mode 100644 index 0000000..4be2461 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp @@ -0,0 +1,221 @@ +#include +#include +#include +#include + +#include "final_grade_determinator/nodes/FinalGradeDeterminator.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" + +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; +}; + +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) override { + stored_results_.push_back({ sc, exam_count, final_grade }); + 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 { + rclcpp::init(0, nullptr); + + test_node_ = std::make_shared("test_node"); + + // Subscriber to capture student 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 messages + exam_publisher_ = test_node_->create_publisher( + "exam_results", 10 + ); + + // Mock service server for grade calculator + grade_calculator_service_ = test_node_->create_service( + "grade_calculator_service", + [this](const g2_2025_interfaces::srv::Exams::Request::SharedPtr request, + g2_2025_interfaces::srv::Exams::Response::SharedPtr response) { + service_requests_.push_back(*request); + // Mock calculation - average of grades + int sum = 0; + for (const auto& grade : request->exam_grades) { + sum += grade; + } + response->result = sum / request->exam_grades.size(); + } + ); + + received_student_messages_.clear(); + service_requests_.clear(); + } + + void TearDown() override { + final_grade_determinator_.reset(); + fake_db_.reset(); + test_node_.reset(); + rclcpp::shutdown(); + } + + void create_final_grade_determinator() { + fake_db_ = std::make_unique(); + final_grade_determinator_ = std::make_shared(std::move(fake_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 (final_grade_determinator_) { + rclcpp::spin_some(final_grade_determinator_); + } + + std::this_thread::sleep_for(10ms); + } + } + + std::unique_ptr fake_db_; + std::shared_ptr test_node_; + std::shared_ptr final_grade_determinator_; + rclcpp::Subscription::SharedPtr student_subscriber_; + rclcpp::Publisher::SharedPtr exam_publisher_; + rclcpp::Service::SharedPtr grade_calculator_service_; + std::vector received_student_messages_; + std::vector service_requests_; +}; + +// ---- TEST CASES ---- + +TEST_F(FinalGradeDeterminatorTest, ConstructorTest) { + ASSERT_NO_THROW({ + create_final_grade_determinator(); + }); + + ASSERT_NE(final_grade_determinator_, nullptr); +} + +TEST_F(FinalGradeDeterminatorTest, ExamCollectionTest) { + create_final_grade_determinator(); + spin_some_time(500ms); + + const std::string student_name = "Test Student"; + const std::string course_name = "Test Course"; + std::vector grades = { 80, 85, 90, 75, 95 }; + + // Send 5 exam results (default collection amount) + for (const auto& grade : grades) { + auto msg = std::make_shared(); + msg->student_name = student_name; + msg->course_name = course_name; + msg->result = grade; + msg->timestamp = test_node_->now(); + + exam_publisher_->publish(*msg); + spin_some_time(50ms); + } + + spin_some_time(1s); + + // Verify service request was made + ASSERT_FALSE(service_requests_.empty()); + EXPECT_EQ(service_requests_.back().student_name, student_name); + EXPECT_EQ(service_requests_.back().course_name, course_name); + EXPECT_EQ(service_requests_.back().exam_grades, grades); + + // Verify student message was published + ASSERT_FALSE(received_student_messages_.empty()); + EXPECT_EQ(received_student_messages_.back().student_name, student_name); + EXPECT_EQ(received_student_messages_.back().course_name, course_name); +} + +TEST_F(FinalGradeDeterminatorTest, PartialExamCollectionTest) { + create_final_grade_determinator(); + spin_some_time(500ms); + + // Send only 3 exam results (less than required 5) + const std::string student_name = "Test Student"; + const std::string course_name = "Test Course"; + std::vector grades = { 80, 85, 90 }; + + for (const auto& grade : grades) { + auto msg = std::make_shared(); + msg->student_name = student_name; + msg->course_name = course_name; + msg->result = grade; + msg->timestamp = test_node_->now(); + + exam_publisher_->publish(*msg); + spin_some_time(50ms); + } + + spin_some_time(1s); + + // Verify no service request was made yet + EXPECT_TRUE(service_requests_.empty()); + // Verify no student message was published + EXPECT_TRUE(received_student_messages_.empty()); +} + +TEST_F(FinalGradeDeterminatorTest, MultipleStudentsTest) { + create_final_grade_determinator(); + spin_some_time(500ms); + + std::vector students = { "Alice", "Bob" }; + const std::string course_name = "Test Course"; + std::vector grades = { 80, 85, 90, 75, 95 }; + + // Send complete set of grades for each student + for (const auto& student : students) { + for (const auto& grade : grades) { + auto msg = std::make_shared(); + msg->student_name = student; + msg->course_name = course_name; + msg->result = grade; + msg->timestamp = test_node_->now(); + + exam_publisher_->publish(*msg); + spin_some_time(50ms); + } + } + + spin_some_time(1s); + + // Verify service requests were made for both students + ASSERT_EQ(service_requests_.size(), 2); + // Verify student messages were published for both students + ASSERT_EQ(received_student_messages_.size(), 2); +} diff --git a/src/g2_2025_grade_calculator_pkg/test/GradeCalculator.test.cpp b/src/g2_2025_grade_calculator_pkg/test/GradeCalculator.test.cpp new file mode 100644 index 0000000..c3d9a65 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/GradeCalculator.test.cpp @@ -0,0 +1,98 @@ +#include +#include +#include +#include +#include "grade_calculator/nodes/GradeCalculator.hpp" + +using namespace std::chrono_literals; +using assignments::one::grade_calculator::GradeCalculator; + +class GradeCalculatorTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() { + int argc = 0; + char** argv = nullptr; + rclcpp::init(argc, argv); + } + + static void TearDownTestSuite() { + rclcpp::shutdown(); + } + + void SetUp() override + { + // Start GradeCalculator node + grade_calculator_node_ = std::make_shared(); + executor_ = std::make_shared(); + executor_->add_node(grade_calculator_node_); + + // Spin in background thread + spin_thread_ = std::thread([this]() { + executor_->spin(); + }); + + // Create client node + client_node_ = rclcpp::Node::make_shared("grade_calculator_test_client"); + client_ = client_node_->create_client("grade_calculator_service"); + } + + void TearDown() override + { + executor_->cancel(); + if (spin_thread_.joinable()) { + spin_thread_.join(); + } + + grade_calculator_node_.reset(); + client_.reset(); + client_node_.reset(); + } + + int call_service(const std::string& name, const std::vector& grades) + { + if (!client_->wait_for_service(5s)) { + throw std::runtime_error("Service not available"); + } + + auto request = std::make_shared(); + request->student_name = name; + request->exam_grades = grades; + + auto future = client_->async_send_request(request); + + if (rclcpp::spin_until_future_complete(client_node_, future) == rclcpp::FutureReturnCode::SUCCESS) { + return future.get()->result; + } else { + throw std::runtime_error("Service call failed"); + } + } + + std::shared_ptr executor_; + std::shared_ptr grade_calculator_node_; + std::thread spin_thread_; + rclcpp::Node::SharedPtr client_node_; + rclcpp::Client::SharedPtr client_; +}; + +// ---- TEST CASES ---- + +TEST_F(GradeCalculatorTest, NormalAverage) { + EXPECT_EQ(call_service("Alice", { 80, 90, 70 }), 80); +} + +TEST_F(GradeCalculatorTest, WesselBonus) { + EXPECT_EQ(call_service("Wessel", { 80, 90, 70 }), 90); +} + +TEST_F(GradeCalculatorTest, wesselBonus) { + EXPECT_EQ(call_service("wessel", { 80, 90, 70 }), 90); +} + +TEST_F(GradeCalculatorTest, GradeTooHigh) { + EXPECT_EQ(call_service("Wessel", { 100, 100, 100 }), 100); +} + +TEST_F(GradeCalculatorTest, GradeTooLow) { + EXPECT_EQ(call_service("Alice", { 0, 0, 0 }), 10); +}