[PR] Implement tests for GradeCalculator and FinalGradeDeterminator, add documentation for aformentioned nodes #4

Merged
vincent merged 8 commits from 1-grade-generator/cijfer-determinator-calculator into 1-grade-generator/master 2025-10-07 10:07:12 +02:00
9 changed files with 425 additions and 7 deletions

2
.gitignore vendored
View File

@@ -33,5 +33,7 @@ qtcreator-*
COLCON_IGNORE COLCON_IGNORE
AMENT_IGNORE AMENT_IGNORE
.vscode
# End of https://www.toptal.com/developers/gitignore/api/ros2 # End of https://www.toptal.com/developers/gitignore/api/ros2

View File

@@ -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` <!--(uses provided or creates new instance) std::unique_ptr<DatabaseManager> db_manager -->
- 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<g2_2025_interfaces::srv::Exams>::SharedFuture future, StudentCourse studentCourseCombo)`**
- Verifies database connection
- Publishes final student message to ROS2 topic
- Logs final grade information
- Stores final course result in database

View File

@@ -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

View File

@@ -127,6 +127,39 @@ if(BUILD_TESTING)
target_link_libraries(${PROJECT_NAME}_test_exam_result_generator target_link_libraries(${PROJECT_NAME}_test_exam_result_generator
pqxx pq tomlplusplus::tomlplusplus 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() endif()
ament_package() ament_package()

View File

@@ -24,10 +24,10 @@ public:
~DatabaseManager() = default; ~DatabaseManager() = default;
bool connect(const std::string& connection_string); bool connect(const std::string& connection_string);
bool is_connected() const; virtual bool is_connected() const;
// Table operations // Table operations
void init_database(); virtual void init_database();
void create_tables(); void create_tables();
void insert_sample_data(); void insert_sample_data();
@@ -35,7 +35,7 @@ public:
std::vector<StudentCourse> queue_pending_combinations(); std::vector<StudentCourse> queue_pending_combinations();
bool store_exam_result(const std::string& student_name, const std::string& course_name, int grade); 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 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); int get_final_course_grade(const StudentCourse& sc);

View File

@@ -2,12 +2,16 @@
namespace assignments::one::final_grade_determinator { namespace assignments::one::final_grade_determinator {
FinalGradeDeterminator::FinalGradeDeterminator() : Node("final_grade_determinator") { FinalGradeDeterminator::FinalGradeDeterminator(std::unique_ptr<DatabaseManager> db_manager) : Node("final_grade_determinator") {
this->declare_parameter("grade_collection_amount", 5); this->declare_parameter("grade_collection_amount", 5);
grade_collection_amount_ = this->get_parameter("grade_collection_amount").as_int(); grade_collection_amount_ = this->get_parameter("grade_collection_amount").as_int();
// Make db_manager optional for testing purposes
if (db_manager) {
db_manager_ = std::move(db_manager);
} else {
db_manager_ = std::make_unique<DatabaseManager>(this->get_logger()); db_manager_ = std::make_unique<DatabaseManager>(this->get_logger());
}
// Create publisher for exam results // Create publisher for exam results
student_publisher_ = this->create_publisher<g2_2025_interfaces::msg::Student>( student_publisher_ = this->create_publisher<g2_2025_interfaces::msg::Student>(
"student_course_management", 10 "student_course_management", 10

View File

@@ -18,7 +18,7 @@ namespace assignments::one::final_grade_determinator {
class FinalGradeDeterminator : public rclcpp::Node { class FinalGradeDeterminator : public rclcpp::Node {
public: public:
FinalGradeDeterminator(); FinalGradeDeterminator(std::unique_ptr<DatabaseManager> db_manager = nullptr);
private: private:
rclcpp::Subscription<g2_2025_interfaces::msg::Exam>::SharedPtr exam_subscriber_; rclcpp::Subscription<g2_2025_interfaces::msg::Exam>::SharedPtr exam_subscriber_;
rclcpp::Publisher<g2_2025_interfaces::msg::Student>::SharedPtr student_publisher_; rclcpp::Publisher<g2_2025_interfaces::msg::Student>::SharedPtr student_publisher_;

View File

@@ -0,0 +1,221 @@
#include <chrono>
#include <memory>
#include <rclcpp/rclcpp.hpp>
#include <gtest/gtest.h>
#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 {
vincent marked this conversation as resolved Outdated

These classes are called "Mock" classes, and thus I'd prefix them as "MockDatabaseManager" or something like that.

These classes are called "Mock" classes, and thus I'd prefix them as "MockDatabaseManager" or something like that.

Also interesting for your own information, GTest has a whole interface and setup around this; https://google.github.io/googletest/gmock_for_dummies.html

Also interesting for your own information, GTest has a whole interface and setup around this; https://google.github.io/googletest/gmock_for_dummies.html
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
vincent marked this conversation as resolved Outdated

I'd recommend keeping structs outside the class, but inside the namespace

I'd recommend keeping structs outside the class, but inside the namespace
}
void init_database() override {
} // no-op
std::vector<MockStoredResult> stored_results_;
};
} // namespace assignments::one
class FinalGradeDeterminatorTest : public ::testing::Test {
protected:
void SetUp() override {
rclcpp::init(0, nullptr);
test_node_ = std::make_shared<rclcpp::Node>("test_node");
// Subscriber to capture student messages
student_subscriber_ = test_node_->create_subscription<g2_2025_interfaces::msg::Student>(
"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<g2_2025_interfaces::msg::Exam>(
"exam_results", 10
);
// Mock service server for grade calculator
grade_calculator_service_ = test_node_->create_service<g2_2025_interfaces::srv::Exams>(
"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<assignments::one::MockDatabaseManager>();
final_grade_determinator_ = std::make_shared<FinalGradeDeterminator>(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_);
}
vincent marked this conversation as resolved Outdated

I personally prefer to split off with newlines in between the variables that are used together (or somewhat)

I personally prefer to split off with newlines in between the variables that are used together (or somewhat)
std::this_thread::sleep_for(10ms);
}
}
std::unique_ptr<assignments::one::MockDatabaseManager> fake_db_;
std::shared_ptr<rclcpp::Node> test_node_;
std::shared_ptr<FinalGradeDeterminator> final_grade_determinator_;
rclcpp::Subscription<g2_2025_interfaces::msg::Student>::SharedPtr student_subscriber_;
rclcpp::Publisher<g2_2025_interfaces::msg::Exam>::SharedPtr exam_publisher_;
rclcpp::Service<g2_2025_interfaces::srv::Exams>::SharedPtr grade_calculator_service_;
std::vector<g2_2025_interfaces::msg::Student> received_student_messages_;
std::vector<g2_2025_interfaces::srv::Exams::Request> 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<int> grades = { 80, 85, 90, 75, 95 };
// Send 5 exam results (default collection amount)
for (const auto& grade : grades) {
auto msg = std::make_shared<g2_2025_interfaces::msg::Exam>();
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<int> grades = { 80, 85, 90 };
for (const auto& grade : grades) {
auto msg = std::make_shared<g2_2025_interfaces::msg::Exam>();
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<std::string> students = { "Alice", "Bob" };
const std::string course_name = "Test Course";
std::vector<int> 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<g2_2025_interfaces::msg::Exam>();
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);
}

View File

@@ -0,0 +1,98 @@
#include <gtest/gtest.h>
#include <rclcpp/rclcpp.hpp>
#include <thread>
#include <g2_2025_interfaces/srv/exams.hpp>
#include "grade_calculator/nodes/GradeCalculator.hpp"
using namespace std::chrono_literals;
using assignments::one::grade_calculator::GradeCalculator;
class GradeCalculatorTest : public ::testing::Test
{
vincent marked this conversation as resolved Outdated

Formatting (check whole document)

Formatting (check whole document)
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<GradeCalculator>();
executor_ = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
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<g2_2025_interfaces::srv::Exams>("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<int>& grades)
{
if (!client_->wait_for_service(5s)) {
throw std::runtime_error("Service not available");
}
auto request = std::make_shared<g2_2025_interfaces::srv::Exams::Request>();
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<rclcpp::executors::SingleThreadedExecutor> executor_;
std::shared_ptr<GradeCalculator> grade_calculator_node_;
std::thread spin_thread_;
rclcpp::Node::SharedPtr client_node_;
rclcpp::Client<g2_2025_interfaces::srv::Exams>::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);
}