Merge pull request '[PR] Implement retake nodes' (#6) from 1-grade-generator/retake-nodes into 1-grade-generator/master

Reviewed-on: http://git.wessel.gg/inholland/ros2-assignments/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
2025-10-09 20:00:41 +02:00
27 changed files with 1500 additions and 110 deletions

View File

@@ -0,0 +1,60 @@
# RetakeGradeDeterminator (`assignments::one::retake_grade_determinator`)
## Overview
The `RetakeGradeDeterminator` node handles the processing of retake exams for students who have
failed courses. It provides a ROS2 action server that accepts retake requests, manages exam result
collection for retake scenarios, and coordinates with the grade calculator service to determine
final retake grades.
#### Implementation Details
**Parameters**
- **`grade_collection_amount`** (int, default: 5): Number of exam results required before triggering grade calculation for a retake student-course combination.
**Constructor**
```cpp
RetakeGradeDeterminator(std::unique_ptr<DatabaseManager> db_manager = nullptr)
```
- Initializes ROS2 node with name `retake_grade_determinator`
- Sets up `DatabaseManager` (optional injection for testing)
- Creates publisher for student course management
- Subscribes to exam results topic with retake-specific callback
- Initializes service client for grade calculation
- Creates ROS2 action server for retake requests with reentrant callback group
**Core Functions**
**`void exam_results_callback(const g2_2025_interfaces::msg::Exam::SharedPtr msg)`**
- Only processes exam results when `retake_allowed_` flag is true
- 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 retake exam grades
**`void grade_calculator_response(rclcpp::Client<g2_2025_interfaces::srv::Exams>::SharedFuture future, StudentCourse studentCourseCombo)`**
- Clears collected exam data from internal map
- Updates retake status in database for the student-course combination
- Publishes final student message to ROS2 topic with timestamp
- Stores final course result in database with retake flag set to true
**Action Server Callbacks**
**`rclcpp_action::GoalResponse goal_callback(const rclcpp_action::GoalUUID& uuid, std::shared_ptr<const g2_2025_interfaces::action::Retake::Goal> goal)`**
- Accepts retake goal requests for specific student-course combinations
- Logs received retake requests with student and course information
- Returns `ACCEPT_AND_EXECUTE` for all valid requests
**`rclcpp_action::CancelResponse cancel_callback(const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle)`**
- Handles retake action cancellation requests
- Returns `ACCEPT` for all cancellation requests
**`void spawn_callback_thread(const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle)`**
- Creates detached thread for asynchronous retake action execution
- Ensures non-blocking retake processing
**`void async_execute_callback_thread(const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle)`**
- Enables retake exam processing by setting `retake_allowed_` flag
- Publishes student enrollment message to trigger exam generation
- Creates and returns successful action result

View File

@@ -0,0 +1,47 @@
# RetakeScheduler (`assignments::one::retake_scheduler`)
## Overview
The `RetakeScheduler` node automatically identifies students who have failed courses and schedules retake exams for them. It periodically queries the database for failing students and sends retake action requests to the `RetakeGradeDeterminator` node to initiate the retake process.
#### Implementation Details
**Parameters**
- **`retake_check_interval_sec`** (int, default: 120): Time interval in seconds between checks for failing students requiring retakes.
**Constructor**
```cpp
RetakeScheduler(std::unique_ptr<DatabaseManager> db_manager = nullptr)
```
- Initializes ROS2 node with name `retake_scheduler`
- Sets up `DatabaseManager` (optional injection for testing)
- Creates ROS2 action client for retake action communication
- Initializes wall timer for periodic failing student checks
**Core Functions**
**`void check_failing_students()`**
- Timer callback function executed periodically based on `retake_check_interval_sec`
- Queries database for all failed course results
- Iterates through failing students and initiates retake requests
**`void send_retake_request(StudentCourse student_course_combo)`**
- Creates retake action goal with student and course information
- Sends asynchronous retake goal to `RetakeGradeDeterminator`
**`void cancel_retake_request()`**
- Cancels all active retake goals
- Provides emergency stop functionality for retake processing
**Action Client Callbacks**
**`void request_response_callback(const rclcpp_action::ClientGoalHandle<RetakeAction>::SharedPtr &goal_handle)`**
- Handles retake action server responses
- Logs goal acceptance or rejection status
- Provides feedback on retake request processing state
**`void request_result_callback(const rclcpp_action::ClientGoalHandle<RetakeAction>::WrappedResult &result)`**
- Processes final retake action results
- Logs appropriate messages based on retake completion status
**`void request_feedback_callback(rclcpp_action::ClientGoalHandle<RetakeAction>::SharedPtr goal_handle, const std::shared_ptr<const RetakeAction::Feedback> feedback)`**

View File

@@ -1,17 +1,17 @@
## Unit Tests
# Config Manager Unit Tests
Unit tests for `ConfigManager` are implemented in `src/g2_2025_grade_calculator_pkg/test/ConfigManager.test.cpp` using Google Test and ROS2 test utilities. The tests use temporary TOML files to validate configuration loading and parsing functionality.
### Test Cases
## Test Cases
#### 1. ConstructorTest
### 1. ConstructorTest
**Description:** Verifies that ConfigManager can be created with a ROS2 logger without crashing.
- **Test Action:** Create ConfigManager instance with ROS2 logger
- **Expected Result:** Instance created successfully without exceptions
#### 2. LoadValidConfigTest
### 2. LoadValidConfigTest
**Description:** Tests loading of valid TOML configuration files with proper parsing.
@@ -22,7 +22,7 @@ Unit tests for `ConfigManager` are implemented in `src/g2_2025_grade_calculator_
- Returns `true`
- `is_loaded()` returns `true`
#### 3. LoadInvalidFileTest
### 3. LoadInvalidFileTest
**Description:** Tests error handling when attempting to load non-existent configuration files.
@@ -31,7 +31,7 @@ Unit tests for `ConfigManager` are implemented in `src/g2_2025_grade_calculator_
- Returns `false`
- `is_loaded()` returns `false`
#### 4. DatabaseConfigParsingTest
### 4. DatabaseConfigParsingTest
**Description:** Tests complete database configuration parsing with all parameters.
@@ -52,7 +52,7 @@ Unit tests for `ConfigManager` are implemented in `src/g2_2025_grade_calculator_
```
- **Expected Result:** All configuration values parsed correctly with proper types
#### 5. DatabaseConfigWithoutPoolTest
### 5. DatabaseConfigWithoutPoolTest
**Description:** Tests default values when optional pool section is missing from configuration.
@@ -69,7 +69,7 @@ Unit tests for `ConfigManager` are implemented in `src/g2_2025_grade_calculator_
- Main database config parsed correctly
- Default pool values: `min_connections = 1`, `max_connections = 10`
#### 6. GetConfigWithoutLoadingTest
### 6. GetConfigWithoutLoadingTest
**Description:** Tests behavior when attempting to access configuration before loading any file.

View File

@@ -1,31 +1,31 @@
## Unit Tests
# Database Manager Unit Tests
Unit tests for `DatabaseManager` are implemented in `src/g2_2025_grade_calculator_pkg/test/DatabaseManager.test.cpp` using Google Test and ROS2 test utilities. The tests are designed to work reliably whether a database connection is available or not, focusing on error handling and method behavior validation.
### Test Cases
## Test Cases
#### 1. ConstructorTest
### 1. ConstructorTest
**Description:** Verifies that DatabaseManager can be created without crashing and proper initialization occurs.
- **Test Action:** Create DatabaseManager instance with ROS2 logger
- **Expected Result:** Instance created successfully without exceptions
#### 2. ConnectionStatusTest
### 2. ConnectionStatusTest
**Description:** Tests that the `is_connected()` method returns a valid boolean value.
- **Test Action:** Call `is_connected()` method
- **Expected Result:** Returns either `true` or `false` (no crashes or invalid states)
#### 3. QueuePendingCombinationsTest
### 3. QueuePendingCombinationsTest
**Description:** Verifies retrieval of pending student-course combinations that need exam results.
- **Test Action:** Call `queue_pending_combinations()`
- **Expected Result:** Returns valid vector (empty if no database connection, populated if connected)
#### 4. StoreExamResultTest
### 4. StoreExamResultTest
**Description:** Tests exam result storage with graceful handling of connection states.
@@ -35,7 +35,7 @@ Unit tests for `DatabaseManager` are implemented in `src/g2_2025_grade_calculato
- Grade: `85`
- **Expected Result:** Returns `false` if no connection, `true` if connected and successful
#### 5. EnrollStudentTest
### 5. EnrollStudentTest
**Description:** Tests student enrollment into courses.
@@ -43,7 +43,7 @@ Unit tests for `DatabaseManager` are implemented in `src/g2_2025_grade_calculato
- StudentCourse object with test data
- **Expected Result:** Returns `false` if no connection, `true` if connected and successful
#### 6. GetFinalGradeTest
### 6. GetFinalGradeTest
**Description:** Tests final grade retrieval for non-existent student-course combinations.
@@ -52,7 +52,7 @@ Unit tests for `DatabaseManager` are implemented in `src/g2_2025_grade_calculato
- Course: `"NonExistentCourse"`
- **Expected Result:** Returns `-1` (no results found or no connection)
#### 7. StoreFinalResultTest
### 7. StoreFinalResultTest
**Description:** Tests storing calculated final course results.

View File

@@ -1,10 +1,10 @@
## Unit Tests
# Exam Result Generator Node Unit Tests
Unit tests for `ExamResultGenerator` are implemented in `src/g2_2025_grade_calculator_pkg/test/ExamResultGenerator.test.cpp` using Google Test and ROS2 test utilities. The tests use a test subscriber node to capture published messages and validate node behavior.
### Test Cases
## Test Cases
#### 1. ConstructorTest
### 1. ConstructorTest
**Description:** Verifies that ExamResultGenerator can be created without crashing and proper initialization occurs.
@@ -13,7 +13,7 @@ Unit tests for `ExamResultGenerator` are implemented in `src/g2_2025_grade_calcu
- Node created successfully without exceptions
- Node name set to `"exam_result_generator"`
#### 2. PublisherCreationTest
### 2. PublisherCreationTest
**Description:** Verifies that the `exam_results` topic publisher is properly configured.
@@ -22,7 +22,7 @@ Unit tests for `ExamResultGenerator` are implemented in `src/g2_2025_grade_calcu
- `/exam_results` topic is published
- Topic uses `g2_2025_interfaces/msg/Exam` message type
#### 3. SubscriberCreationTest
### 3. SubscriberCreationTest
**Description:** Tests that the node subscribes to the `student_course_management` topic with correct message type.
@@ -31,7 +31,7 @@ Unit tests for `ExamResultGenerator` are implemented in `src/g2_2025_grade_calcu
- `/student_course_management` topic is subscribed
- Subscription uses `g2_2025_interfaces/msg/Student` message type
#### 4. StudentManagementMessageHandlingTest
### 4. StudentManagementMessageHandlingTest
**Description:** Tests the node's ability to handle incoming student management messages without crashing.
@@ -41,7 +41,7 @@ Unit tests for `ExamResultGenerator` are implemented in `src/g2_2025_grade_calcu
- Course: `"Test Course"`
- **Expected Result:** Message processed without crashes
#### 5. MultipleStudentMessagesTest
### 5. MultipleStudentMessagesTest
**Description:** Validates handling of multiple rapid student management messages for robustness testing.
@@ -51,7 +51,7 @@ Unit tests for `ExamResultGenerator` are implemented in `src/g2_2025_grade_calcu
- Courses: `["Math", "Physics", "Chemistry"]`
- **Expected Result:** All messages processed without crashes or memory issues
#### 6. ExamMessageValidationTest
### 6. ExamMessageValidationTest
**Description:** Captures and validates published exam result messages for correct content and format.

View File

@@ -1,17 +1,17 @@
## Unit Tests
# Final Grade Determintator Node Unit Tests
Unit tests for `FinalGradeDeterminator` are implemented in `src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp` using Google Test and ROS2 test utilities. The tests use a mock database manager and a mock grade calculator service to simulate the node's interactions.
### Test Cases
## Test Cases
#### 1. ConstructorTest
### 1. ConstructorTest
**Description:** Verifies that the node can be constructed without errors.
- **Input:** Construct a `FinalGradeDeterminator` node.
- **Expected Output:** No exceptions are thrown. The node is created successfully.
#### 2. ExamCollectionTest
### 2. ExamCollectionTest
**Description:** Sends the required number of exam results and checks that a grade calculation request is triggered, a student message is published, and the results are stored.
@@ -24,7 +24,7 @@ Unit tests for `FinalGradeDeterminator` are implemented in `src/g2_2025_grade_ca
- A student message is published with the correct student and course.
- The final grade is calculated as the average: (80 + 85 + 90 + 75 + 95) / 5 = 85.
#### 3. PartialExamCollectionTest
### 3. PartialExamCollectionTest
**Description:** Sends fewer than the required number of exam results and checks that no grade calculation or student message occurs.
@@ -36,7 +36,7 @@ Unit tests for `FinalGradeDeterminator` are implemented in `src/g2_2025_grade_ca
- No grade calculation service request is made.
- No student message is published.
#### 4. MultipleStudentsTest
### 4. MultipleStudentsTest
**Description:** Simulates multiple students submitting exam results and verifies that each student triggers independent grade calculation and messaging.

View File

@@ -1,10 +1,10 @@
## Unit Tests
# Grade Calculator Node Unit Tests
Unit tests for `GradeCalculator` are implemented in `src/g2_2025_grade_calculator_pkg/test/GradeCalculator.test.cpp` using Google Test and ROS2 test utilities. The tests use a client to call the grade calculation service and verify the results.
### Test Cases
## Test Cases
#### 1. NormalAverage
### 1. NormalAverage
**Description:** Verifies that the service returns the average of the provided grades for a normal student name.
@@ -14,7 +14,7 @@ Unit tests for `GradeCalculator` are implemented in `src/g2_2025_grade_calculato
- **Expected Output:**
- Result: `80` (average of 80, 90, 70)
#### 2. WesselBonus
### 2. WesselBonus
**Description:** Checks that the student name "Wessel" (case-insensitive) receives a 10-point bonus, and the result is clamped to 100 if necessary.

View File

@@ -0,0 +1,151 @@
# Integration Test
Tests the whole integrated system `nodes` connected with each other, with supervisory control and visualization of the database. It tests all major components working together: exam result generation, calculation, database usage, and retake handling.
For validation and verification of database data the databse viewer Dbeaver can be used.
## Test Input Data
**Student Enrollments:**
- `"Wessel", "ROS2"`
- `"Vincent", "ROS2"`
- `"Mohammed", "Differentieren"`
- `"Tilmann", "Differentieren"`
## Test workflow
### 1. Complete Grade Processing Pipeline
**Description**: Tests the full workflow from student enrollment through exam generation to final grade calculation.
**Steps:**
1. **Setup Phase**
- Clear the database via `docker container prune` or by deleting the tables in dbeaver.
- Start the databse in a standalone terminal via the command `sudo docker compose up`.
- Verify database connection.
- Initialize all system nodes (ExamResultGenerator, FinalGradeDeterminator, GradeCalculator) after building tha package and starting it via the `grade_calculator.launch.xml`.
2. **Exam Generation Phase**
- ExamResultGenerator publishes Exam messages per student-course pair to table `exam_results` topic.
- Verify exam results have valid grades (10-100 range).
- Confirm proper timestamp and student/course name matching.
3. **Grade Collection Phase**
- FinalGradeDeterminator collects exam results for each student-course combination.
- Monitor collection count reaches threshold (5 exams per student-course by default) for each student.
4. **Grade Calculation Phase**
- FinalGradeDeterminator calls GradeCalculator service when threshold reached.
- Service calculates average grade.
5. **Persistence and Notification Phase**
- Final grades stored in database with proper timestamps.
- verify that final_grade_determinator has sent a de-enroll request via student_course_managment to exam_result_generator.
- exam_result_generator should now prints `removed from queue`.
- Verify final grade is within valid bounds (10-100) in table `final_course_results`.
**Expected Results:**
```text
Student: Wessel, Course: ROS2
- 5 exam results generated (random grades 10-100)
- Avrage final grade calculated
- Grade stored in database
Student: Vincent, Course: ROS2
- 5 exam results generated (random grades 10-100)
- Avrage final grade calculated
- Grade stored in database
Student: Mohammed, Course: Differentieren
- 5 exam results generated (random grades 10-100)
- Avrage final grade calculated
- Grade stored in database
Student: Tilmann, Course: Differentieren
- 5 exam results generated (random grades 10-100)
- Avrage final grade calculated
- Grade stored in database
```
### 2. Retake Process Integration
**Description**: Tests the retake workflow for students who need to retake exams.
**Setup:**
no setup required, this starts immediately after `final_grade_diterminator` is done after getting the first grades for the students.
**Steps:**
1. **Varify Retake Schedualer is started**
- Verify in the terminal the schedualer starts the retake process.
2. **Retake Request Phase**
- RetakeScheduler checks the database for failing students
- Schedules retake exams with `retake_grade_determinator`
3. **Retake Execution Phase**
- Generate retake exam results
- `retake_grade_determinator` processes retake grades and sends them to the calcculator.
- `retake_grade_determinator` finaly stores the calculated result in the database.
4. **Final Grade Update**
- Verify grade history preservation
- Confirm final grade reflects retake performance
**Failing student Expected Retake Results:**
```text
Student: student, Course: course
- 5 exam results generated (random grades 10-100)
- Avrage final grade calculated
- Grade stored in database
- Retake_done checkmark
- Is_retake checkmark
```
## Success Criteria
### Functional Requirements
All student enrollments processed correctly
Exam results generated within valid range (10-100)
Grade collection reaches configured thresholds
Final grades calculated using proper business logic
Database persistence works reliably
Retake process functions correctly when needed
All ROS2 communication protocols working
## Test Execution Commands
```bash
# 1. enter the ros2 assignment folder
cd /YOUR/PATH/HERE/ros2-assignment
# 2. Build the packages and source the enviroment
colcon build && source install/setup.bash
# 3. clear and Start database (if not running) in stand alone terminal
sudo docker container prune && sudo docker compose up
# 4. Launch all system nodes
ros2 launch g2_2025_grade_calculator_pkg grade_calculator.launch.xml
# 6. Monitor database state
By opening dbeaver and connecting to the database "grades".
Password: postgres
```
## Verification
### Database Validation
- **Student Records**: Verify all students are enrolled correctly
- **Exam Results**: Confirm all exam data is good
- **Final Grades**: Validate calculated grades and timestamps

View File

@@ -0,0 +1,70 @@
# RetakeGradeDeterminator Node Unit Tests
This document describes the unit tests for the `RetakeGradeDeterminator` ROS 2 node, as implemented in [`RetakeGradeDeterminator.test.cpp`](../../src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp).
## Test Cases
### 1. ConstructorTest
- **Purpose:**
Verifies that the `RetakeGradeDeterminator` can be constructed without throwing exceptions.
- **Checks:**
- The node is created successfully and is not `nullptr`.
### 2. PublisherCreationTest
- **Purpose:**
Ensures that the node creates a publisher for the `student_course_management` topic.
- **Checks:**
- The `/student_course_management` topic is present and uses the `Student` message type.
### 3. SubscriberCreationTest
- **Purpose:**
Ensures that the node subscribes to the `exam_results` topic.
- **Checks:**
- The `/exam_results` topic is present and uses the `Exam` message type.
### 4. ActionServerCreationTest
- **Purpose:**
Verifies that the node creates an action server for the `retake_action`.
- **Checks:**
- The action server is available and can be connected to by an action client.
### 5. ServiceClientCreationTest
- **Purpose:**
Ensures that the node creates a client for the `grade_calculator_service`.
- **Checks:**
- The `/grade_calculator_service` is present in the node's service graph.
### 6. ParameterTest
- **Purpose:**
Confirms that the node's parameters are set to their expected default values.
- **Checks:**
- The `grade_collection_amount` parameter defaults to `5`.
### 7. ExamResultsIgnoredWhenRetakeNotAllowed
- **Purpose:**
Ensures that exam results are ignored if a retake is not currently allowed.
- **Checks:**
- No service requests are made when a retake is not in progress.
### 8. RetakeActionGoalAcceptance
- **Purpose:**
Verifies that the node accepts retake action goals and publishes the appropriate student enrollment message.
- **Checks:**
- The retake goal is accepted.
- A `Student` message is published with the correct student and course names.
### 9. PartialExamResultsCollection
- **Purpose:**
Ensures that the node does not process or store results if not enough exam results have been collected during a retake.
- **Checks:**
- No service requests are made.
- No results are stored in the database if the required number of exam results has not been reached.

View File

@@ -0,0 +1,50 @@
# RetakeScheduler Node Unit Tests
Unit tests for `RetakeScheduler` are implemented in `src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp`
The unit tests are written using Google Test and ROS 2 test utilities. They utilize clients to interact
with the RetakeScheduler node's services and actions, verifying correct behavior and expected outcomes.
## Test Cases
### 1. ConstructorTest
- **Purpose:**
Verifies that the `RetakeScheduler` can be constructed without throwing exceptions.
- **Checks:**
- The node is created successfully and is not `nullptr`.
### 2. ActionClientCreationTest
- **Purpose:**
Ensures that the `RetakeScheduler` node creates the expected action client services for the retake action.
- **Checks:**
- At least one of the retake action client services (`/retake_action/_action/cancel_goal`, `/retake_action/_action/get_result`, `/retake_action/_action/send_goal`) is present in the node's service graph.
### 3. ParameterTest
- **Purpose:**
Confirms that the node's parameters are set to their expected default values.
- **Checks:**
- The `retake_check_interval_sec` parameter defaults to `120`.
### 4. ActionClientAllServicesPresentTest
- **Purpose:**
Verifies that all required action client services for the retake action are available.
- **Checks:**
- The following services are present:
- `/retake_action/_action/cancel_goal`
- `/retake_action/_action/get_result`
- `/retake_action/_action/send_goal`
### 5. ConstructorWithNullDatabaseManager
- **Purpose:**
Tests the node's behavior when constructed with a `nullptr` database manager.
- **Checks:**
- The node can be constructed without throwing, even if the database manager is `nullptr`.

View File

@@ -19,6 +19,8 @@ fetchcontent_makeavailable(tomlplusplus)
# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED)
find_package(std_msgs REQUIRED)
find_package(g2_2025_interfaces REQUIRED)
add_executable(exam_result_generator
@@ -56,13 +58,46 @@ ament_target_dependencies(grade_calculator rclcpp g2_2025_interfaces)
add_executable(retake_grade_determinator
src/retake_grade_determinator/main.cpp
src/database/DatabaseManager.cpp
src/config/ConfigManager.cpp
src/retake_grade_determinator/nodes/RetakeGradeDeterminator.cpp
)
ament_target_dependencies(retake_grade_determinator rclcpp g2_2025_interfaces)
ament_target_dependencies(retake_grade_determinator rclcpp_action rclcpp g2_2025_interfaces)
target_link_libraries(retake_grade_determinator pqxx pq tomlplusplus::tomlplusplus)
add_executable(retake_scheduler
src/retake_scheduler/main.cpp
src/database/DatabaseManager.cpp
src/config/ConfigManager.cpp
src/retake_scheduler/nodes/RetakeScheduler.cpp
)
ament_target_dependencies(retake_scheduler rclcpp_action rclcpp g2_2025_interfaces)
target_link_libraries(retake_scheduler pqxx pq tomlplusplus::tomlplusplus)
target_include_directories(retake_scheduler PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/retake_scheduler
${CMAKE_CURRENT_SOURCE_DIR}/src/g2_2025_grade_calculator_pkg/database
)
target_include_directories(retake_scheduler PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/retake_scheduler
${CMAKE_CURRENT_SOURCE_DIR}/src/g2_2025_grade_calculator_pkg/config
)
target_include_directories(retake_grade_determinator PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/retake_grade_determinator
${CMAKE_CURRENT_SOURCE_DIR}/src/g2_2025_grade_calculator_pkg/database
)
target_include_directories(retake_grade_determinator PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/retake_grade_determinator
${CMAKE_CURRENT_SOURCE_DIR}/src/g2_2025_grade_calculator_pkg/config
)
ament_target_dependencies(retake_scheduler rclcpp g2_2025_interfaces)
install(
TARGETS
@@ -166,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()

View File

@@ -7,5 +7,8 @@
</node>
<node pkg="g2_2025_grade_calculator_pkg" exec="grade_calculator"/>
<node pkg="g2_2025_grade_calculator_pkg" exec="retake_grade_determinator"/>
<node pkg="g2_2025_grade_calculator_pkg" exec="retake_scheduler"/>
<node pkg="g2_2025_grade_calculator_pkg" exec="retake_scheduler">
<param name="retake_check_interval_sec" value="120"/>
</node>
</launch>

View File

@@ -295,4 +295,23 @@ std::vector<StudentCourse> DatabaseManager::get_failed_course_results() {
return failed_courses;
}
bool DatabaseManager::update_retake_status(const StudentCourse& sc) {
if (!is_connected()) {
return false;
}
try {
pqxx::work txn(*conn_);
txn.exec_params(SQL_UPDATE_RETAKE_STATUS, sc.student_name, sc.course_name);
txn.commit();
return true;
} catch (const pqxx::sql_error &e) {
RCLCPP_ERROR(logger_, "[DBS] 'update retake status' failed: %s", e.what());
return false;
} catch (const std::exception &e) {
RCLCPP_ERROR(logger_, "[DBS] database error: %s", e.what());
return false;
}
}
} // namespace assignments::one

View File

@@ -45,6 +45,7 @@ public:
int get_final_course_grade(const StudentCourse& sc);
std::vector<StudentCourse> get_failed_course_results();
bool update_retake_status(const StudentCourse &sc);
private:
rclcpp::Logger logger_;

View File

@@ -36,6 +36,7 @@ static const std::string SQL_CREATE_COURSE_RESULTS = R"(
exam_count INTEGER NOT NULL,
final_grade INTEGER NOT NULL,
is_retake BOOLEAN DEFAULT FALSE,
retake_done BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
)";
@@ -64,7 +65,7 @@ static const std::string SQL_SELECT_STUDENT_EXAM_RESULTS = R"(
static const std::string SQL_SELECT_FAILED_COURSE_RESULTS = R"(
SELECT fcr.student_name, fcr.course_name, fcr.final_grade, fcr.exam_count
FROM final_course_results fcr
WHERE fcr.final_grade < 55 AND fcr.is_retake = FALSE
WHERE fcr.final_grade < 55 AND fcr.retake_done = FALSE
)";
static const std::string SQL_INSERT_EXAM_RESULT = R"(
@@ -89,6 +90,12 @@ static const std::string SQL_SELECT_STUDENT_LIST = R"(
SELECT COUNT(*) FROM student_enrollments
)";
static const std::string SQL_UPDATE_RETAKE_STATUS = R"(
UPDATE final_course_results
SET retake_done = TRUE
WHERE student_name = $1 AND course_name = $2 AND retake_done = FALSE
)";
static const std::string SQL_INSERT_FINAL_COURSE_RESULT = R"(
INSERT INTO final_course_results (student_name, course_name, exam_count, final_grade, is_retake)
VALUES ($1, $2, $3, $4, $5)

View File

@@ -10,28 +10,14 @@
* [04-09-2025] Wessel T: Implement template
*/
#include <cstdlib>
#include "rclcpp/rclcpp.hpp"
namespace lessons::zero::tmp {
class NodeTemplate : public rclcpp::Node {
public:
NodeTemplate()
: Node("node_template")
{
}
private:
};
} // namespace lessons::zero::template
#include "nodes/RetakeGradeDeterminator.hpp"
int main(int argc,char *argv[]) {
rclcpp::init(argc,argv);
auto node = std::make_shared<lessons::zero::tmp::NodeTemplate>();
auto node = std::make_shared<assignments::one::retake_grade_determinator::RetakeGradeDeterminator>();
rclcpp::spin(node);
rclcpp::shutdown();

View File

@@ -0,0 +1,161 @@
#include "RetakeGradeDeterminator.hpp"
namespace assignments::one::retake_grade_determinator {
RetakeGradeDeterminator::RetakeGradeDeterminator(std::unique_ptr<DatabaseManager> db_manager) : Node("retake_grade_determinator") {
this->declare_parameter("grade_collection_amount", 5);
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());
}
// Create publisher for exam results
student_publisher_ = this->create_publisher<g2_2025_interfaces::msg::Student>(
"student_course_management", 10
);
// Create subscriber for adding/removing student/course combinations
exam_subscriber_ = this->create_subscription<g2_2025_interfaces::msg::Exam>(
"exam_results", 10,
std::bind(
&RetakeGradeDeterminator::exam_results_callback,
this,
std::placeholders::_1
)
);
exam_service_client_ = this->create_client<g2_2025_interfaces::srv::Exams>("grade_calculator_service");
// Create action server for retake requests
callback_group_ = this->create_callback_group(rclcpp::CallbackGroupType::Reentrant);
retake_actionserver_ = rclcpp_action::create_server<g2_2025_interfaces::action::Retake>(
this,
"retake_action",
std::bind(&RetakeGradeDeterminator::goal_callback, this, std::placeholders::_1, std::placeholders::_2),
std::bind(&RetakeGradeDeterminator::cancel_callback, this, std::placeholders::_1),
std::bind(&RetakeGradeDeterminator::spawn_callback_thread, this, std::placeholders::_1),
rcl_action_server_get_default_options(),
callback_group_
);
RCLCPP_INFO(this->get_logger(), "Action server has been started.");
}
void RetakeGradeDeterminator::exam_results_callback(
const g2_2025_interfaces::msg::Exam::SharedPtr msg
) {
if (retake_allowed_) {
student_course_combo_.student_name = msg->student_name;
student_course_combo_.course_name = msg->course_name;
data_map_[student_course_combo_].push_back(msg->result);
auto grade_collection_as_ulong = static_cast<unsigned long>(grade_collection_amount_);
if (data_map_[student_course_combo_].size() == grade_collection_as_ulong) {
RCLCPP_INFO(this->get_logger(),
"%s // %s: results sent to calculator",
msg->student_name.c_str(), msg->course_name.c_str()
);
grade_calculator_request(student_course_combo_);
}
}
}
void RetakeGradeDeterminator::grade_calculator_request(StudentCourse combo) {
if (!exam_service_client_->wait_for_service(std::chrono::seconds(1))) {
RCLCPP_WARN(this->get_logger(), "Service not available");
return;
}
auto request = std::make_shared<g2_2025_interfaces::srv::Exams::Request>();
request->course_name = combo.course_name;
request->student_name = combo.student_name;
request->exam_grades = data_map_[combo];
// Callback is used due to ros2 not liking passing multiple arguments in async calls
auto callback = [this, combo](rclcpp::Client<g2_2025_interfaces::srv::Exams>::SharedFuture future) {
this->grade_calculator_response(future, combo);
};
exam_service_client_->async_send_request(request, callback);
}
void RetakeGradeDeterminator::grade_calculator_response(
rclcpp::Client<g2_2025_interfaces::srv::Exams>::SharedFuture future,
StudentCourse studentCourseCombo
) {
if (!db_manager_ || !db_manager_->is_connected()) {
RCLCPP_WARN(this->get_logger(), "no database connection");
return;
}
data_map_[studentCourseCombo].clear();
db_manager_->update_retake_status(studentCourseCombo);
auto response = future.get();
auto student_message = g2_2025_interfaces::msg::Student();
student_message.student_name = studentCourseCombo.student_name;
student_message.course_name = studentCourseCombo.course_name;
student_message.timestamp = this->now();
student_publisher_->publish(student_message);
RCLCPP_INFO(this->get_logger(),
"%s // %s is %d",
studentCourseCombo.student_name.c_str(), studentCourseCombo.course_name.c_str(), response->result
);
db_manager_->store_final_course_result(
studentCourseCombo,
grade_collection_amount_,
response->result,
true
);
}
rclcpp_action::GoalResponse RetakeGradeDeterminator::goal_callback(
const rclcpp_action::GoalUUID& uuid,
std::shared_ptr<const g2_2025_interfaces::action::Retake::Goal> goal
) {
(void)uuid;
RCLCPP_INFO(this->get_logger(),
"Received retake goal request for student: %s, course: %s",
goal->student_name.c_str(), goal->course_name.c_str());
RCLCPP_INFO(this->get_logger(), "Retake goal accepted");
return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
}
rclcpp_action::CancelResponse RetakeGradeDeterminator::cancel_callback(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle
) {
(void)goal_handle;
RCLCPP_INFO(this->get_logger(), "Received request to cancel retake goal");
return rclcpp_action::CancelResponse::ACCEPT;
}
void RetakeGradeDeterminator::spawn_callback_thread(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle
) { // Spawn a new thread to prevent blocking the executor
std::thread{std::bind(&RetakeGradeDeterminator::async_execute_callback_thread, this, std::placeholders::_1), goal_handle}.detach();
}
void RetakeGradeDeterminator::async_execute_callback_thread(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle
) {
retake_allowed_ = true;
RCLCPP_INFO(this->get_logger(), "Executing retake action for %s // %s", goal_handle->get_goal()->student_name.c_str(), goal_handle->get_goal()->course_name.c_str());
auto student_message = g2_2025_interfaces::msg::Student();
student_message.student_name = goal_handle->get_goal()->student_name;
student_message.course_name = goal_handle->get_goal()->course_name;
student_message.timestamp = this->now();
student_publisher_->publish(student_message);
auto result = std::make_shared<g2_2025_interfaces::action::Retake::Result>();
result->result = 0.0;
goal_handle->succeed(result);
}
} // namespace assignments::one::retake_grade_determinator

View File

@@ -0,0 +1,61 @@
#pragma once
#include <memory>
#include <string>
#include <random>
#include <vector>
#include <chrono>
#include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.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 "database/DatabaseManager.hpp"
#include "database/StudentCourse.hpp"
namespace assignments::one::retake_grade_determinator {
class RetakeGradeDeterminator : public rclcpp::Node {
public:
RetakeGradeDeterminator(std::unique_ptr<DatabaseManager> db_manager = nullptr);
private:
rclcpp::Subscription<g2_2025_interfaces::msg::Exam>::SharedPtr exam_subscriber_;
rclcpp::Publisher<g2_2025_interfaces::msg::Student>::SharedPtr student_publisher_;
rclcpp_action::Server<g2_2025_interfaces::action::Retake>::SharedPtr retake_actionserver_;
rclcpp::CallbackGroup::SharedPtr callback_group_;
rclcpp::Client<g2_2025_interfaces::srv::Exams>::SharedPtr exam_service_client_;
std::unique_ptr<DatabaseManager> db_manager_;
StudentCourse student_course_combo_;
StudentCourseResultMap data_map_;
// Params
int grade_collection_amount_;
bool retake_allowed_ = false;
void grade_calculator_request(StudentCourse combo);
void exam_results_callback(const g2_2025_interfaces::msg::Exam::SharedPtr msg);
void grade_calculator_response(
rclcpp::Client<g2_2025_interfaces::srv::Exams>::SharedFuture future,
StudentCourse studentCourseCombo
);
rclcpp_action::GoalResponse goal_callback(
const rclcpp_action::GoalUUID & uuid,
std::shared_ptr<const g2_2025_interfaces::action::Retake::Goal> goal);
rclcpp_action::CancelResponse cancel_callback(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle);
void spawn_callback_thread(const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle);
void async_execute_callback_thread(const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle);
};
} // namespace assignments::one::retake_grade_determinator

View File

@@ -13,25 +13,14 @@
#include <cstdlib>
#include "rclcpp/rclcpp.hpp"
#include "nodes/RetakeScheduler.hpp"
namespace lessons::zero::tmp {
class NodeTemplate : public rclcpp::Node {
public:
NodeTemplate()
: Node("node_template")
{
}
private:
};
} // namespace lessons::zero::template
int main(int argc,char *argv[]) {
rclcpp::init(argc,argv);
auto node = std::make_shared<lessons::zero::tmp::NodeTemplate>();
auto node = std::make_shared<assignments::one::retake_scheduler::RetakeScheduler>();
rclcpp::spin(node);
rclcpp::shutdown();

View File

@@ -0,0 +1,104 @@
#include "RetakeScheduler.hpp"
namespace assignments::one::retake_scheduler {
RetakeScheduler::RetakeScheduler(std::unique_ptr<DatabaseManager> db_manager) : Node("retake_scheduler") {
this->declare_parameter("retake_check_interval_sec", 120); // Default to 120 seconds
retake_check_interval_ = this->get_parameter("retake_check_interval_sec").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());
}
// Create action client to communicate with RetakeGradeDeterminator
retake_action_client_ = rclcpp_action::create_client<RetakeAction>(this, "retake_action");
// Check for failing students periodically
timer_ = this->create_wall_timer(
std::chrono::seconds(retake_check_interval_),
std::bind(&RetakeScheduler::check_failing_students, this)
);
}
void RetakeScheduler::check_failing_students() {
RCLCPP_INFO(this->get_logger(), "Checking for failing students...");
std::vector<StudentCourse> failing_students = db_manager_->get_failed_course_results();
if (failing_students.empty()) {
RCLCPP_INFO(this->get_logger(), "No failing students found.");
return;
}
for (const auto& student_course_combo : failing_students) {
send_retake_request(student_course_combo);
}
}
void RetakeScheduler::send_retake_request(StudentCourse student_course_combo) {
if (!retake_action_client_->wait_for_action_server(std::chrono::seconds(5))) {
RCLCPP_ERROR(this->get_logger(), "Retake action server not available after waiting");
return;
}
auto goal_msg = RetakeAction::Goal();
goal_msg.student_name = student_course_combo.student_name;
goal_msg.course_name = student_course_combo.course_name;
RCLCPP_INFO(this->get_logger(), "Sending retake request for %s // %s", student_course_combo.student_name.c_str(), student_course_combo.course_name.c_str());
auto send_goal_options = rclcpp_action::Client<RetakeAction>::SendGoalOptions();
send_goal_options.goal_response_callback = std::bind(&RetakeScheduler::request_response_callback, this, std::placeholders::_1);
send_goal_options.result_callback = std::bind(&RetakeScheduler::request_result_callback, this, std::placeholders::_1);
send_goal_options.feedback_callback = std::bind(&RetakeScheduler::request_feedback_callback, this, std::placeholders::_1, std::placeholders::_2);
retake_action_client_->async_send_goal(goal_msg, send_goal_options);
RCLCPP_INFO(this->get_logger(), "Retake request sent.");
}
void RetakeScheduler::cancel_retake_request() {
retake_action_client_->async_cancel_all_goals();
RCLCPP_INFO(this->get_logger(), "Sent cancel request for all retake goals.");
}
void RetakeScheduler::request_response_callback(
const rclcpp_action::ClientGoalHandle<RetakeAction>::SharedPtr &goal_handle
) {
if (!goal_handle) {
RCLCPP_ERROR(this->get_logger(), "Retake goal was rejected by server");
} else {
RCLCPP_INFO(this->get_logger(), "Retake goal accepted by server, waiting for result");
}
}
void RetakeScheduler::request_result_callback(
const rclcpp_action::ClientGoalHandle<RetakeAction>::WrappedResult &result
) {
switch (result.code) {
case rclcpp_action::ResultCode::SUCCEEDED:
RCLCPP_INFO(this->get_logger(), "Retake goal succeeded");
break;
case rclcpp_action::ResultCode::ABORTED:
RCLCPP_ERROR(this->get_logger(), "Retake goal was aborted");
return;
case rclcpp_action::ResultCode::CANCELED:
RCLCPP_ERROR(this->get_logger(), "Retake goal was canceled");
return;
default:
RCLCPP_ERROR(this->get_logger(), "Unknown result code");
return;
}
RCLCPP_INFO(this->get_logger(), "Retake result received: %.2f", result.result->result);
}
void RetakeScheduler::request_feedback_callback(
rclcpp_action::ClientGoalHandle<RetakeAction>::SharedPtr goal_handle,
const std::shared_ptr<const RetakeAction::Feedback> feedback
) {
(void)goal_handle;
RCLCPP_INFO(this->get_logger(), "Received feedback: %s", feedback->status.c_str());
}
} // namespace assignments::one::retake_scheduler

View File

@@ -0,0 +1,49 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include <chrono>
#include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.hpp"
#include "g2_2025_interfaces/action/retake.hpp"
#include "database/DatabaseManager.hpp"
#include "database/StudentCourse.hpp"
namespace assignments::one::retake_scheduler {
class RetakeScheduler : public rclcpp::Node {
public:
RetakeScheduler(std::unique_ptr<DatabaseManager> db_manager = nullptr);
private:
rclcpp::TimerBase::SharedPtr timer_;
std::unique_ptr<DatabaseManager> db_manager_;
using RetakeAction = g2_2025_interfaces::action::Retake;
rclcpp_action::Client<RetakeAction>::SharedPtr retake_action_client_;
int retake_check_interval_;
void send_retake_request(StudentCourse combo);
void cancel_retake_request();
void check_failing_students();
void request_response_callback(
const rclcpp_action::ClientGoalHandle<RetakeAction>::SharedPtr &goal_handle
);
void request_result_callback(
const rclcpp_action::ClientGoalHandle<RetakeAction>::WrappedResult &result
);
void request_feedback_callback(
rclcpp_action::ClientGoalHandle<RetakeAction>::SharedPtr goal_handle,
const std::shared_ptr<const RetakeAction::Feedback> feedback
);
};
} // namespace assignments::one::retake_scheduler

View File

@@ -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<MockStoredResult> stored_results_;
};
} // namespace assignments::one
class FinalGradeDeterminatorTest : public ::testing::Test {
protected:
void SetUp() override {

View File

@@ -0,0 +1,338 @@
#include <chrono>
#include <memory>
#include <rclcpp/rclcpp.hpp>
#include <rclcpp_action/rclcpp_action.hpp>
#include <gtest/gtest.h>
#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<rclcpp::Node>("test_node");
// Create mock database manager
mock_db_ = std::make_unique<assignments::one::MockDatabaseManager>();
ptr_mock_db_ = mock_db_.get();
// Subscriber to capture student management 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 results
exam_publisher_ = test_node_->create_publisher<g2_2025_interfaces::msg::Exam>(
"exam_results", 10
);
// Service server to mock grade calculator
grade_calculator_service_ = test_node_->create_service<g2_2025_interfaces::srv::Exams>(
"grade_calculator_service",
[this](const std::shared_ptr<g2_2025_interfaces::srv::Exams::Request> request,
std::shared_ptr<g2_2025_interfaces::srv::Exams::Response> response) {
service_requests_.push_back(*request);
response->result = 75;
}
);
// Action client to test retake action server
retake_action_client_ = rclcpp_action::create_client<g2_2025_interfaces::action::Retake>(
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<RetakeGradeDeterminator>(
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<rclcpp::Node> test_node_;
std::unique_ptr<assignments::one::MockDatabaseManager> mock_db_;
assignments::one::MockDatabaseManager* ptr_mock_db_;
std::shared_ptr<RetakeGradeDeterminator> retake_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_;
rclcpp_action::Client<g2_2025_interfaces::action::Retake>::SharedPtr retake_action_client_;
std::vector<g2_2025_interfaces::msg::Student> received_student_messages_;
std::vector<g2_2025_interfaces::srv::Exams::Request> 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<g2_2025_interfaces::msg::Exam>();
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<g2_2025_interfaces::action::Retake>::SendGoalOptions();
send_goal_options.goal_response_callback =
[&goal_accepted](
std::shared_ptr<rclcpp_action::ClientGoalHandle<g2_2025_interfaces::action::Retake>> 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<g2_2025_interfaces::action::Retake>::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<g2_2025_interfaces::msg::Exam>();
// 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<g2_2025_interfaces::action::Retake>::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<g2_2025_interfaces::msg::Exam>();
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";
}

View File

@@ -0,0 +1,123 @@
#include <chrono>
#include <memory>
#include <rclcpp/rclcpp.hpp>
#include <rclcpp_action/rclcpp_action.hpp>
#include <gtest/gtest.h>
#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<rclcpp::Node>("test_node");
mock_action_server_ = std::make_unique<assignments::one::MockRetakeActionServer>(test_node_);
// Create mock database manager
mock_db_ = std::make_unique<assignments::one::MockDatabaseManager>();
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<RetakeScheduler>(
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<rclcpp::Node> test_node_;
std::shared_ptr<RetakeScheduler> retake_scheduler_;
std::unique_ptr<assignments::one::MockDatabaseManager> mock_db_;
assignments::one::MockDatabaseManager* mock_db_ptr_;
std::unique_ptr<assignments::one::MockRetakeActionServer> 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_sec");
EXPECT_EQ(param.as_int(), 120) << "Default retake_check_interval should be 120 seconds";
}
TEST_F(RetakeSchedulerTest, ActionClientAllServicesPresentTest) {
create_retake_scheduler();
// Verify all 3 action services are visible on the graph
auto service_names_and_types = retake_scheduler_->get_service_names_and_types();
bool has_cancel = false, has_get_result = false, has_send_goal = false;
for (const auto& [ service_name, _ ] : service_names_and_types) {
if (service_name == "/retake_action/_action/cancel_goal") has_cancel = true;
if (service_name == "/retake_action/_action/get_result") has_get_result = true;
if (service_name == "/retake_action/_action/send_goal") has_send_goal = true;
}
EXPECT_TRUE(has_cancel) << "cancel_goal service should be available";
EXPECT_TRUE(has_get_result) << "get_result service should be available";
EXPECT_TRUE(has_send_goal) << "send_goal service should be available";
}
TEST_F(RetakeSchedulerTest, ConstructorWithNullDatabaseManager) {
// Explicitly construct with nullptr to exercise optional DB manager path
ASSERT_NO_THROW({
retake_scheduler_ = std::make_shared<RetakeScheduler>(nullptr);
});
ASSERT_NE(retake_scheduler_, nullptr);
}

View File

@@ -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<StudentCourse> 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<StudentCourse>& failed_students) {
failed_students_ = failed_students;
}
std::vector<MockStoredResult> stored_results;
std::vector<StudentCourse> retake_status_updates;
private:
std::vector<StudentCourse> failed_students_;
bool connection_status_ = true;
};
} // namespace assignments::one

View File

@@ -0,0 +1,57 @@
#pragma once
namespace assignments::one {
class MockRetakeActionServer {
public:
MockRetakeActionServer(std::shared_ptr<rclcpp::Node> node) : node_(node) {
action_server_ = rclcpp_action::create_server<g2_2025_interfaces::action::Retake>(
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<const g2_2025_interfaces::action::Retake::Goal> goal
) {
(void)uuid;
received_goals_.push_back(*goal);
return goal_response_;
}
rclcpp_action::CancelResponse handle_cancel(
const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle
) {
(void)goal_handle;
cancel_requests++;
return rclcpp_action::CancelResponse::ACCEPT;
}
void handle_accepted(const std::shared_ptr<rclcpp_action::ServerGoalHandle<g2_2025_interfaces::action::Retake>> goal_handle) {
accepted_goals++;
// Simulate immediate success
auto result = std::make_shared<g2_2025_interfaces::action::Retake::Result>();
result->result = 0.0;
goal_handle->succeed(result);
}
void set_goal_response(rclcpp_action::GoalResponse response) {
goal_response_ = response;
}
std::vector<g2_2025_interfaces::action::Retake::Goal> received_goals_;
int accepted_goals = 0;
int cancel_requests = 0;
private:
std::shared_ptr<rclcpp::Node> node_;
rclcpp_action::Server<g2_2025_interfaces::action::Retake>::SharedPtr action_server_;
rclcpp_action::GoalResponse goal_response_ = rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
};
} // namespace assignments::one

View File

@@ -1,5 +1,6 @@
string student_name
string course_name
---
---
float32 result
---
string status