diff --git a/doc/architecture/nodes/RetakeGradeDeterminator.md b/doc/architecture/nodes/RetakeGradeDeterminator.md new file mode 100644 index 0000000..dec9d18 --- /dev/null +++ b/doc/architecture/nodes/RetakeGradeDeterminator.md @@ -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 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::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 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> goal_handle)`** +- Handles retake action cancellation requests +- Returns `ACCEPT` for all cancellation requests + +**`void spawn_callback_thread(const std::shared_ptr> goal_handle)`** +- Creates detached thread for asynchronous retake action execution +- Ensures non-blocking retake processing + +**`void async_execute_callback_thread(const std::shared_ptr> 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 diff --git a/doc/architecture/nodes/RetakeScheduler.md b/doc/architecture/nodes/RetakeScheduler.md new file mode 100644 index 0000000..c4f6c79 --- /dev/null +++ b/doc/architecture/nodes/RetakeScheduler.md @@ -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 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::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::WrappedResult &result)`** +- Processes final retake action results +- Logs appropriate messages based on retake completion status + +**`void request_feedback_callback(rclcpp_action::ClientGoalHandle::SharedPtr goal_handle, const std::shared_ptr feedback)`** diff --git a/doc/tests/ConfigManager.md b/doc/tests/ConfigManager.md index 192691d..71cb9a8 100644 --- a/doc/tests/ConfigManager.md +++ b/doc/tests/ConfigManager.md @@ -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. diff --git a/doc/tests/DatabaseManager.md b/doc/tests/DatabaseManager.md index b90b1ab..7a5abd6 100644 --- a/doc/tests/DatabaseManager.md +++ b/doc/tests/DatabaseManager.md @@ -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. diff --git a/doc/tests/ExamResultGenerator.md b/doc/tests/ExamResultGenerator.md index 7fbbf7f..b0adbda 100644 --- a/doc/tests/ExamResultGenerator.md +++ b/doc/tests/ExamResultGenerator.md @@ -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. diff --git a/doc/tests/FinalGradeDeterminator.md b/doc/tests/FinalGradeDeterminator.md index 4b6cce0..fb94255 100644 --- a/doc/tests/FinalGradeDeterminator.md +++ b/doc/tests/FinalGradeDeterminator.md @@ -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. diff --git a/doc/tests/GradeCalculator.md b/doc/tests/GradeCalculator.md index 767a95f..479623f 100644 --- a/doc/tests/GradeCalculator.md +++ b/doc/tests/GradeCalculator.md @@ -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. diff --git a/doc/tests/IntegrationTest.md b/doc/tests/IntegrationTest.md new file mode 100644 index 0000000..09a9bb3 --- /dev/null +++ b/doc/tests/IntegrationTest.md @@ -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 diff --git a/doc/tests/RetakeGradeDeterminator.md b/doc/tests/RetakeGradeDeterminator.md new file mode 100644 index 0000000..47de946 --- /dev/null +++ b/doc/tests/RetakeGradeDeterminator.md @@ -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. diff --git a/doc/tests/RetakeScheduler.md b/doc/tests/RetakeScheduler.md new file mode 100644 index 0000000..95dd12b --- /dev/null +++ b/doc/tests/RetakeScheduler.md @@ -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`. + diff --git a/src/g2_2025_grade_calculator_pkg/CMakeLists.txt b/src/g2_2025_grade_calculator_pkg/CMakeLists.txt index a2e2777..66604bb 100644 --- a/src/g2_2025_grade_calculator_pkg/CMakeLists.txt +++ b/src/g2_2025_grade_calculator_pkg/CMakeLists.txt @@ -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() diff --git a/src/g2_2025_grade_calculator_pkg/launch/grade_calculator.launch.xml b/src/g2_2025_grade_calculator_pkg/launch/grade_calculator.launch.xml index ab2ff34..aa5672c 100644 --- a/src/g2_2025_grade_calculator_pkg/launch/grade_calculator.launch.xml +++ b/src/g2_2025_grade_calculator_pkg/launch/grade_calculator.launch.xml @@ -7,5 +7,8 @@ - + + + + diff --git a/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.cpp b/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.cpp index f895e37..e7b347a 100644 --- a/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.cpp +++ b/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.cpp @@ -295,4 +295,23 @@ std::vector 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 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 09c41cd..66f6d3c 100644 --- a/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.hpp +++ b/src/g2_2025_grade_calculator_pkg/src/database/DatabaseManager.hpp @@ -45,6 +45,7 @@ public: int get_final_course_grade(const StudentCourse& sc); std::vector get_failed_course_results(); + bool update_retake_status(const StudentCourse &sc); private: rclcpp::Logger logger_; diff --git a/src/g2_2025_grade_calculator_pkg/src/database/SQLQueries.hpp b/src/g2_2025_grade_calculator_pkg/src/database/SQLQueries.hpp index 0b9f5a8..cc0047d 100644 --- a/src/g2_2025_grade_calculator_pkg/src/database/SQLQueries.hpp +++ b/src/g2_2025_grade_calculator_pkg/src/database/SQLQueries.hpp @@ -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) diff --git a/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/main.cpp b/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/main.cpp index 7279717..f386358 100644 --- a/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/main.cpp +++ b/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/main.cpp @@ -10,28 +10,14 @@ * [04-09-2025] Wessel T: Implement template */ -#include - #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(); + auto node = std::make_shared(); rclcpp::spin(node); rclcpp::shutdown(); diff --git a/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/nodes/RetakeGradeDeterminator.cpp b/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/nodes/RetakeGradeDeterminator.cpp new file mode 100644 index 0000000..44b09bf --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/nodes/RetakeGradeDeterminator.cpp @@ -0,0 +1,161 @@ +#include "RetakeGradeDeterminator.hpp" + +namespace assignments::one::retake_grade_determinator { + +RetakeGradeDeterminator::RetakeGradeDeterminator(std::unique_ptr 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(this->get_logger()); + } + // Create publisher for exam results + student_publisher_ = this->create_publisher( + "student_course_management", 10 + ); + + // Create subscriber for adding/removing student/course combinations + exam_subscriber_ = this->create_subscription( + "exam_results", 10, + std::bind( + &RetakeGradeDeterminator::exam_results_callback, + this, + std::placeholders::_1 + ) + ); + + exam_service_client_ = this->create_client("grade_calculator_service"); + + // Create action server for retake requests + callback_group_ = this->create_callback_group(rclcpp::CallbackGroupType::Reentrant); + retake_actionserver_ = rclcpp_action::create_server( + 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(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(); + 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::SharedFuture future) { + this->grade_calculator_response(future, combo); + }; + exam_service_client_->async_send_request(request, callback); +} + +void RetakeGradeDeterminator::grade_calculator_response( + rclcpp::Client::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 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> 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> 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> 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(); + result->result = 0.0; + goal_handle->succeed(result); +} + +} // namespace assignments::one::retake_grade_determinator diff --git a/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/nodes/RetakeGradeDeterminator.hpp b/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/nodes/RetakeGradeDeterminator.hpp new file mode 100644 index 0000000..6a47878 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/src/retake_grade_determinator/nodes/RetakeGradeDeterminator.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 db_manager = nullptr); +private: + rclcpp::Subscription::SharedPtr exam_subscriber_; + rclcpp::Publisher::SharedPtr student_publisher_; + + rclcpp_action::Server::SharedPtr retake_actionserver_; + rclcpp::CallbackGroup::SharedPtr callback_group_; + + rclcpp::Client::SharedPtr exam_service_client_; + + std::unique_ptr 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::SharedFuture future, + StudentCourse studentCourseCombo + ); + + rclcpp_action::GoalResponse goal_callback( + const rclcpp_action::GoalUUID & uuid, + std::shared_ptr goal); + + rclcpp_action::CancelResponse cancel_callback( + const std::shared_ptr> goal_handle); + + void spawn_callback_thread(const std::shared_ptr> goal_handle); + void async_execute_callback_thread(const std::shared_ptr> goal_handle); +}; + +} // namespace assignments::one::retake_grade_determinator diff --git a/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/main.cpp b/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/main.cpp index 7279717..e4f3577 100644 --- a/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/main.cpp +++ b/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/main.cpp @@ -13,25 +13,14 @@ #include #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(); + auto node = std::make_shared(); rclcpp::spin(node); rclcpp::shutdown(); diff --git a/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/nodes/RetakeScheduler.cpp b/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/nodes/RetakeScheduler.cpp new file mode 100644 index 0000000..e7ffc90 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/nodes/RetakeScheduler.cpp @@ -0,0 +1,104 @@ +#include "RetakeScheduler.hpp" + +namespace assignments::one::retake_scheduler { + +RetakeScheduler::RetakeScheduler(std::unique_ptr 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(this->get_logger()); + } + // Create action client to communicate with RetakeGradeDeterminator + retake_action_client_ = rclcpp_action::create_client(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 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::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::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::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::SharedPtr goal_handle, + const std::shared_ptr feedback +) { + (void)goal_handle; + RCLCPP_INFO(this->get_logger(), "Received feedback: %s", feedback->status.c_str()); +} + +} // namespace assignments::one::retake_scheduler diff --git a/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/nodes/RetakeScheduler.hpp b/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/nodes/RetakeScheduler.hpp new file mode 100644 index 0000000..0e24217 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/src/retake_scheduler/nodes/RetakeScheduler.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +#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 db_manager = nullptr); + +private: + rclcpp::TimerBase::SharedPtr timer_; + std::unique_ptr db_manager_; + + using RetakeAction = g2_2025_interfaces::action::Retake; + rclcpp_action::Client::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::SharedPtr &goal_handle + ); + + void request_result_callback( + const rclcpp_action::ClientGoalHandle::WrappedResult &result + ); + + void request_feedback_callback( + rclcpp_action::ClientGoalHandle::SharedPtr goal_handle, + const std::shared_ptr feedback + ); +}; + +} // namespace assignments::one::retake_scheduler diff --git a/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp b/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp index c4c728f..3a7f374 100644 --- a/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp +++ b/src/g2_2025_grade_calculator_pkg/test/FinalGradeDeterminator.test.cpp @@ -9,46 +9,11 @@ #include "g2_2025_interfaces/msg/student.hpp" #include "g2_2025_interfaces/srv/exams.hpp" +#include "mocks/MockDatabaseManager.hpp" + using namespace std::chrono_literals; using namespace assignments::one::final_grade_determinator; -namespace assignments::one { - -struct MockStoredResult { - StudentCourse sc; - int exam_count; - int final_grade; - bool is_retake; -}; - -class MockDatabaseManager : public DatabaseManager { -public: - explicit MockDatabaseManager(rclcpp::Logger logger = rclcpp::get_logger("fake_db")) - : DatabaseManager(logger) { - } - - bool is_connected() const override { - return true; // Always pretend we are connected - } - - bool store_final_course_result( - const StudentCourse& sc, - int exam_count, - int final_grade, - bool is_retake - ) override { - stored_results_.push_back({ sc, exam_count, final_grade, is_retake }); - return true; // Always succeed - } - - void init_database() override { - } // no-op - - std::vector stored_results_; -}; - -} // namespace assignments::one - class FinalGradeDeterminatorTest : public ::testing::Test { protected: void SetUp() override { diff --git a/src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp b/src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp new file mode 100644 index 0000000..abda39f --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/RetakeGradeDeterminator.test.cpp @@ -0,0 +1,338 @@ +#include +#include +#include +#include +#include + +#include "retake_grade_determinator/nodes/RetakeGradeDeterminator.hpp" +#include "database/DatabaseManager.hpp" +#include "g2_2025_interfaces/msg/exam.hpp" +#include "g2_2025_interfaces/msg/student.hpp" +#include "g2_2025_interfaces/srv/exams.hpp" +#include "g2_2025_interfaces/action/retake.hpp" + +#include "mocks/MockDatabaseManager.hpp" + +using namespace std::chrono_literals; +using namespace assignments::one::retake_grade_determinator; + +class RetakeGradeDeterminatorTest : public ::testing::Test { +protected: + void SetUp() override { + rclcpp::init(0, nullptr); + + // Create a test node for testing communication + test_node_ = std::make_shared("test_node"); + + // Create mock database manager + mock_db_ = std::make_unique(); + ptr_mock_db_ = mock_db_.get(); + + // Subscriber to capture student management messages + student_subscriber_ = test_node_->create_subscription( + "student_course_management", 10, + [this](const g2_2025_interfaces::msg::Student::SharedPtr msg) { + received_student_messages_.push_back(*msg); + } + ); + + // Publisher to send exam results + exam_publisher_ = test_node_->create_publisher( + "exam_results", 10 + ); + + // Service server to mock grade calculator + grade_calculator_service_ = test_node_->create_service( + "grade_calculator_service", + [this](const std::shared_ptr request, + std::shared_ptr response) { + service_requests_.push_back(*request); + response->result = 75; + } + ); + + // Action client to test retake action server + retake_action_client_ = rclcpp_action::create_client( + test_node_, "retake_action" + ); + + received_student_messages_.clear(); + service_requests_.clear(); + } + + void TearDown() override { + retake_grade_determinator_.reset(); + test_node_.reset(); + rclcpp::shutdown(); + } + + void create_retake_grade_determinator() { + retake_grade_determinator_ = std::make_shared( + std::move(mock_db_) + ); + } + + void spin_some_time(std::chrono::milliseconds duration = 100ms) { + auto start_time = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start_time < duration) { + rclcpp::spin_some(test_node_); + + if (retake_grade_determinator_) { + rclcpp::spin_some(retake_grade_determinator_); + } + + std::this_thread::sleep_for(10ms); + } + } + + std::shared_ptr test_node_; + + std::unique_ptr mock_db_; + assignments::one::MockDatabaseManager* ptr_mock_db_; + std::shared_ptr retake_grade_determinator_; + + rclcpp::Subscription::SharedPtr student_subscriber_; + rclcpp::Publisher::SharedPtr exam_publisher_; + rclcpp::Service::SharedPtr grade_calculator_service_; + rclcpp_action::Client::SharedPtr retake_action_client_; + + std::vector received_student_messages_; + std::vector service_requests_; +}; + +TEST_F(RetakeGradeDeterminatorTest, ConstructorTest) { + ASSERT_NO_THROW(create_retake_grade_determinator()); + + ASSERT_NE(retake_grade_determinator_, nullptr); +} + +TEST_F(RetakeGradeDeterminatorTest, PublisherCreationTest) { + create_retake_grade_determinator(); + + bool student_management_topic_found = false; + auto topic_names_and_types = retake_grade_determinator_->get_topic_names_and_types(); + + for (const auto& [ topic_name, topic_types ] : topic_names_and_types) { + if (topic_name == "/student_course_management") { + student_management_topic_found = true; + bool correct_type = false; + + for (const auto& type : topic_types) { + if (type == "g2_2025_interfaces/msg/Student") { + correct_type = true; + break; + } + } + + EXPECT_TRUE(correct_type) + << "student_course_management topic should have Student message type"; + break; + } + } + + EXPECT_TRUE(student_management_topic_found) + << "student_course_management topic should be published"; +} + +TEST_F(RetakeGradeDeterminatorTest, SubscriberCreationTest) { + create_retake_grade_determinator(); + + bool exam_results_topic_found = false; + auto topic_names_and_types = retake_grade_determinator_->get_topic_names_and_types(); + + for (const auto& [ topic_name, topic_types ] : topic_names_and_types) { + if (topic_name == "/exam_results") { + exam_results_topic_found = true; + bool correct_type = false; + + for (const auto& type : topic_types) { + if (type == "g2_2025_interfaces/msg/Exam") { + correct_type = true; + break; + } + } + + EXPECT_TRUE(correct_type) + << "exam_results topic should have Exam message type"; + break; + } + } + + EXPECT_TRUE(exam_results_topic_found) + << "exam_results topic should be subscribed"; +} + +TEST_F(RetakeGradeDeterminatorTest, ActionServerCreationTest) { + create_retake_grade_determinator(); + + // Allow time for action server to be created + spin_some_time(500ms); + + // Check if action client can connect to the server + bool server_available = retake_action_client_->wait_for_action_server( + std::chrono::seconds(2) + ); + + EXPECT_TRUE(server_available) + << "Retake action server should be available"; +} + +TEST_F(RetakeGradeDeterminatorTest, ServiceClientCreationTest) { + create_retake_grade_determinator(); + + auto service_names_and_types = retake_grade_determinator_->get_service_names_and_types(); + + bool grade_calculator_service_found = false; + for (const auto& [ service_name, service_types ] : service_names_and_types) { + if (service_name == "/grade_calculator_service") { + grade_calculator_service_found = true; + break; + } + } + + EXPECT_TRUE(grade_calculator_service_found) + << "grade_calculator_service should be available as client"; +} + +TEST_F(RetakeGradeDeterminatorTest, ParameterTest) { + create_retake_grade_determinator(); + + auto param = retake_grade_determinator_->get_parameter("grade_collection_amount"); + EXPECT_EQ(param.as_int(), 5) + << "Default grade_collection_amount should be 5"; +} + +TEST_F(RetakeGradeDeterminatorTest, ExamResultsIgnoredWhenRetakeNotAllowed) { + create_retake_grade_determinator(); + + auto exam_msg = std::make_shared(); + exam_msg->student_name = "tilmann"; + exam_msg->course_name = "computeren"; + exam_msg->result = 85; + + exam_publisher_->publish(*exam_msg); + spin_some_time(200ms); + + EXPECT_TRUE(service_requests_.empty()) + << "No service requests should be made when retake not allowed"; +} + +TEST_F(RetakeGradeDeterminatorTest, RetakeActionGoalAcceptance) { + create_retake_grade_determinator(); + + ASSERT_TRUE(retake_action_client_->wait_for_action_server(std::chrono::seconds(2))); + + auto goal = g2_2025_interfaces::action::Retake::Goal(); + goal.student_name = "tilmann"; + goal.course_name = "differentieren"; + + bool goal_accepted = false; + auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); + + send_goal_options.goal_response_callback = + [&goal_accepted]( + std::shared_ptr> goal_handle + ) { + if (goal_handle) { + goal_accepted = true; + } + }; + + retake_action_client_->async_send_goal(goal, send_goal_options); + // Give asynchronous action service time to process + spin_some_time(500ms); + + EXPECT_TRUE(goal_accepted) + << "Retake goal should be accepted"; + EXPECT_EQ(received_student_messages_.size(), 1) + << "Should publish student enrollment message"; + + if (!received_student_messages_.empty()) { + EXPECT_EQ(received_student_messages_[0].student_name, "tilmann"); + EXPECT_EQ(received_student_messages_[0].course_name, "differentieren"); + } +} + +// TEST_F(RetakeGradeDeterminatorTest, ExamResultsProcessingDuringRetake) { +// create_retake_grade_determinator(); + +// ASSERT_TRUE(retake_action_client_->wait_for_action_server(std::chrono::seconds(2))); + +// auto goal = g2_2025_interfaces::action::Retake::Goal(); +// goal.student_name = "tilmann"; +// goal.course_name = "differentieren"; + +// auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); +// retake_action_client_->async_send_goal(goal, send_goal_options); +// spin_some_time(300ms); + +// received_student_messages_.clear(); + +// for (int i = 0; i < 5; ++i) { +// auto exam_msg = std::make_shared(); +// exam_msg->student_name = "tilmann"; +// exam_msg->course_name = "differentieren"; +// exam_msg->result = 70 + i; + +// exam_publisher_->publish(*exam_msg); +// } + +// spin_some_time(500ms); + +// EXPECT_EQ(service_requests_.size(), 1) +// << "Should make one service request after collecting required exam results"; + +// if (!service_requests_.empty()) { +// EXPECT_EQ(service_requests_[0].student_name, "tilmann"); +// EXPECT_EQ(service_requests_[0].course_name, "differentieren"); +// EXPECT_EQ(service_requests_[0].exam_grades.size(), 5); +// } + +// // Should publish final result +// EXPECT_EQ(received_student_messages_.size(), 1) +// << "Should publish final student result"; + +// // Should store in database with retake flag +// EXPECT_EQ(ptr_mock_db_->stored_results.size(), 1); +// if (!ptr_mock_db_->stored_results.empty()) { +// EXPECT_TRUE(ptr_mock_db_->stored_results[0].is_retake) +// << "Should store result with retake flag"; +// EXPECT_EQ(ptr_mock_db_->stored_results[0].final_grade, 75); +// } + +// EXPECT_EQ(ptr_mock_db_->retake_status_updates.size(), 1); +// } + +TEST_F(RetakeGradeDeterminatorTest, PartialExamResultsCollection) { + create_retake_grade_determinator(); + + // spin_some_time(500ms); + + ASSERT_TRUE(retake_action_client_->wait_for_action_server(std::chrono::seconds(2))); + + auto goal = g2_2025_interfaces::action::Retake::Goal(); + goal.student_name = "tilmann"; + goal.course_name = "differentieren"; + + auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); + retake_action_client_->async_send_goal(goal, send_goal_options); + spin_some_time(300ms); + + // Send only 3 out of 5 required exam results + for (int i = 0; i < 3; ++i) { + auto exam_msg = std::make_shared(); + exam_msg->student_name = "tilmann"; + exam_msg->course_name = "differentieren"; + exam_msg->result = 65 + i; + + exam_publisher_->publish(*exam_msg); + } + + spin_some_time(300ms); + + EXPECT_TRUE(service_requests_.empty()) + << "Should not make service request with insufficient exam results"; + EXPECT_TRUE(ptr_mock_db_->stored_results.empty()) + << "Should not store results with insufficient exam results"; +} diff --git a/src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp b/src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp new file mode 100644 index 0000000..a17ca69 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/RetakeScheduler.test.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include +#include + +#include "retake_scheduler/nodes/RetakeScheduler.hpp" +#include "database/DatabaseManager.hpp" +#include "g2_2025_interfaces/action/retake.hpp" + +#include "mocks/MockDatabaseManager.hpp" +#include "mocks/MockRetakeActionServer.hpp" + +using namespace std::chrono_literals; +using namespace assignments::one::retake_scheduler; + +class RetakeSchedulerTest : public ::testing::Test { +protected: + void SetUp() override { + rclcpp::init(0, nullptr); + + // Create test node and mock action server + test_node_ = std::make_shared("test_node"); + mock_action_server_ = std::make_unique(test_node_); + + // Create mock database manager + mock_db_ = std::make_unique(); + mock_db_ptr_ = mock_db_.get(); + } + + void TearDown() override { + retake_scheduler_.reset(); + mock_action_server_.reset(); + test_node_.reset(); + rclcpp::shutdown(); + } + + void create_retake_scheduler() { + retake_scheduler_ = std::make_shared( + std::move(mock_db_) + ); + } + + void spin_some_time(std::chrono::milliseconds duration = 100ms) { + auto start_time = std::chrono::steady_clock::now(); + + while (std::chrono::steady_clock::now() - start_time < duration) { + rclcpp::spin_some(test_node_); + + if (retake_scheduler_) { + rclcpp::spin_some(retake_scheduler_); + } + + std::this_thread::sleep_for(10ms); + } + } + + std::shared_ptr test_node_; + std::shared_ptr retake_scheduler_; + std::unique_ptr mock_db_; + assignments::one::MockDatabaseManager* mock_db_ptr_; + std::unique_ptr mock_action_server_; +}; + +TEST_F(RetakeSchedulerTest, ConstructorTest) { + ASSERT_NO_THROW(create_retake_scheduler()); + + ASSERT_NE(retake_scheduler_, nullptr); +} + +TEST_F(RetakeSchedulerTest, ActionClientCreationTest) { + create_retake_scheduler(); + + bool retake_action_found = false; + auto service_names_and_types = retake_scheduler_->get_service_names_and_types(); + + for (const auto& [ service_name, service_types ] : service_names_and_types) { + if (service_name == "/retake_action/_action/cancel_goal" || + service_name == "/retake_action/_action/get_result" || + service_name == "/retake_action/_action/send_goal") { + retake_action_found = true; + break; + } + } + + EXPECT_TRUE(retake_action_found) + << "Retake action client services should be available"; +} + +TEST_F(RetakeSchedulerTest, ParameterTest) { + create_retake_scheduler(); + + // Test default parameter value + auto param = retake_scheduler_->get_parameter("retake_check_interval_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(nullptr); + }); + ASSERT_NE(retake_scheduler_, nullptr); +} + diff --git a/src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp b/src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp new file mode 100644 index 0000000..621ab2c --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/mocks/MockDatabaseManager.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "database/DatabaseManager.hpp" + +namespace assignments::one { + +struct MockStoredResult { + StudentCourse sc; + int exam_count; + int final_grade; + bool is_retake; +}; + +class MockDatabaseManager : public DatabaseManager { +public: + explicit MockDatabaseManager( + rclcpp::Logger logger = rclcpp::get_logger("fake_db") + ) + : DatabaseManager(logger) {} + + bool is_connected() const override { + return connection_status_; + } + + void init_database() override {} + + void set_connection_status(bool status) { + connection_status_ = status; + } + + std::vector get_failed_course_results() { + return failed_students_; + } + + bool store_final_course_result( + const StudentCourse& sc, + int exam_count, + int final_grade, + bool is_retake + ) { + stored_results.push_back({ sc, exam_count, final_grade, is_retake }); + return true; + } + + bool update_retake_status(const StudentCourse& sc) { + retake_status_updates.push_back(sc); + return true; + } + + void clear_failed_students() { + failed_students_.clear(); + } + + void add_failed_student(const std::string& student_name, const std::string& course_name) { + StudentCourse sc; + sc.student_name = student_name; + sc.course_name = course_name; + failed_students_.push_back(sc); + } + + void set_failed_students(const std::vector& failed_students) { + failed_students_ = failed_students; + } + + std::vector stored_results; + std::vector retake_status_updates; + +private: + std::vector failed_students_; + bool connection_status_ = true; +}; + +} // namespace assignments::one diff --git a/src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp b/src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp new file mode 100644 index 0000000..aef0431 --- /dev/null +++ b/src/g2_2025_grade_calculator_pkg/test/mocks/MockRetakeActionServer.hpp @@ -0,0 +1,57 @@ +#pragma once + +namespace assignments::one { + +class MockRetakeActionServer { +public: + MockRetakeActionServer(std::shared_ptr node) : node_(node) { + action_server_ = rclcpp_action::create_server( + node_, + "retake_action", + std::bind(&MockRetakeActionServer::handle_goal, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&MockRetakeActionServer::handle_cancel, this, std::placeholders::_1), + std::bind(&MockRetakeActionServer::handle_accepted, this, std::placeholders::_1) + ); + } + + rclcpp_action::GoalResponse handle_goal( + const rclcpp_action::GoalUUID & uuid, + std::shared_ptr goal + ) { + (void)uuid; + received_goals_.push_back(*goal); + return goal_response_; + } + + rclcpp_action::CancelResponse handle_cancel( + const std::shared_ptr> goal_handle + ) { + (void)goal_handle; + cancel_requests++; + return rclcpp_action::CancelResponse::ACCEPT; + } + + void handle_accepted(const std::shared_ptr> goal_handle) { + accepted_goals++; + + // Simulate immediate success + auto result = std::make_shared(); + result->result = 0.0; + goal_handle->succeed(result); + } + + void set_goal_response(rclcpp_action::GoalResponse response) { + goal_response_ = response; + } + + std::vector received_goals_; + int accepted_goals = 0; + int cancel_requests = 0; + +private: + std::shared_ptr node_; + rclcpp_action::Server::SharedPtr action_server_; + rclcpp_action::GoalResponse goal_response_ = rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE; +}; + +} // namespace assignments::one diff --git a/src/g2_2025_interfaces/action/Retake.action b/src/g2_2025_interfaces/action/Retake.action index bc8d36b..737390b 100644 --- a/src/g2_2025_interfaces/action/Retake.action +++ b/src/g2_2025_interfaces/action/Retake.action @@ -1,5 +1,6 @@ string student_name string course_name --- ---- float32 result +--- +string status