#include "application/services/TaskScheduler.h" #include "mocks/TestLogger.h" #include "mocks/MockTimeProvider.h" #include "mocks/MockThreadManager.h" #include "mocks/MockBlocker.h" #include #include #include #include using trompeloeil::_; using namespace nxl::autostore; using namespace std::chrono; using nxl::autostore::application::TaskScheduler; namespace test { // Fixed test timepoint: 2020-01-01 12:00 constexpr std::chrono::system_clock::time_point TIMEPOINT_NOW = std::chrono::system_clock::time_point(std::chrono::seconds(1577880000)); } // namespace test TEST_CASE("TaskScheduler Unit Tests", "[unit][TaskScheduler]") { // Common mock objects that all sections can use auto logger = std::make_shared(); auto timeProvider = std::make_unique(); auto threadMgr = std::make_unique(); auto blocker = std::make_unique(); SECTION("when start is called then createThread is called") { // Given // Expect createThread to be called REQUIRE_CALL(*threadMgr, createThread(_)) .RETURN(std::make_unique()); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(blocker)); // When scheduler.start(); } SECTION("when scheduler is created then it is not running") { // Given - recreate blocker for this test since it was moved in previous // section auto testBlocker = std::make_unique(); // When TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); // Then - calling stop on a non-running scheduler should not cause issues // and no thread operations should be called FORBID_CALL(*threadMgr, createThread(_)); scheduler.stop(); } SECTION("when task is scheduled with OnStart mode then it executes " "immediately after start") { // Given bool taskExecuted = false; std::function threadFn; // Recreate blocker for this test auto testBlocker = std::make_unique(); // Expect createThread to be called, save thread function REQUIRE_CALL(*threadMgr, createThread(_)) .RETURN(std::make_unique()) .LR_SIDE_EFFECT(threadFn = std::move(_1)); ALLOW_CALL(*timeProvider, now()).LR_RETURN(test::TIMEPOINT_NOW); FORBID_CALL(*testBlocker, blockFor(_)); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); auto taskFunction = [&]() { taskExecuted = true; scheduler.stop(); // prevent infinite loop in threadFn }; // When scheduler.schedule(taskFunction, 0, 0, 0, TaskScheduler::RunMode::OnStart); scheduler.start(); threadFn(); // Then REQUIRE(taskExecuted); scheduler.stop(); } SECTION( "when task is scheduled with Once mode then it executes at specified time") { // Given auto threadHandle = std::make_unique(); bool taskExecuted = false; std::function threadFn; auto currentTime = test::TIMEPOINT_NOW; // current "now", starts at 12:00 std::chrono::seconds timeDelta{5}; std::chrono::milliseconds actualDelay{0}; auto initialTime = test::TIMEPOINT_NOW; auto expectedExecutionTime = initialTime + timeDelta; // Set up thread handle expectations before moving it ALLOW_CALL(*threadHandle, join()); ALLOW_CALL(*threadHandle, joinable()).RETURN(true); // Recreate blocker for this test auto testBlocker = std::make_unique(); // Expect createThread to be called, save thread function REQUIRE_CALL(*threadMgr, createThread(_)) .LR_RETURN(std::move(threadHandle)) .LR_SIDE_EFFECT(threadFn = std::move(_1)); // Mock time provider calls - return initial time first, then execution time ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); // Allow blocker calls, save delay value ALLOW_CALL(*testBlocker, blockFor(_)) .LR_SIDE_EFFECT(actualDelay += _1; currentTime += _1 // let the time flow ); ALLOW_CALL(*testBlocker, notify()); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); auto taskFunction = [&]() { taskExecuted = true; scheduler.stop(); // prevent infinite loop in threadFn }; // When scheduler.schedule(taskFunction, 12, 0, timeDelta.count(), TaskScheduler::RunMode::Once); scheduler.start(); // Execute the thread function to simulate the scheduler thread threadFn(); // Then REQUIRE(taskExecuted); REQUIRE(actualDelay == timeDelta); } SECTION("when task is scheduled with Forever and OnStart mode then it " "executes repeatedly") { // Given auto threadHandle = std::make_unique(); std::function threadFn; int executionCount = 0; auto currentTime = test::TIMEPOINT_NOW; // Set up thread handle expectations before moving it ALLOW_CALL(*threadHandle, join()); ALLOW_CALL(*threadHandle, joinable()).RETURN(true); // Recreate blocker for this test auto testBlocker = std::make_unique(); // Expect createThread to be called, save thread function REQUIRE_CALL(*threadMgr, createThread(_)) .LR_RETURN(std::move(threadHandle)) .LR_SIDE_EFFECT(threadFn = std::move(_1)); // Mock time provider calls ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); // Allow blocker calls and simulate time passage ALLOW_CALL(*testBlocker, blockFor(_)).LR_SIDE_EFFECT(currentTime += _1); ALLOW_CALL(*testBlocker, notify()); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); auto taskFunction = [&]() { executionCount++; if (executionCount >= 3) { scheduler.stop(); // stop after 3 executions } }; // When scheduler.schedule(taskFunction, 0, 0, 0, TaskScheduler::RunMode::Forever | TaskScheduler::RunMode::OnStart); scheduler.start(); // Execute the thread function to simulate the scheduler thread threadFn(); // Then REQUIRE(executionCount >= 3); } SECTION("when invalid time parameters are provided then exception is thrown") { // Given - recreate blocker for this test auto testBlocker = std::make_unique(); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); // When & Then - invalid hour REQUIRE_THROWS_AS( scheduler.schedule([]() {}, -1, 0, 0, TaskScheduler::RunMode::Once), std::invalid_argument); REQUIRE_THROWS_AS( scheduler.schedule([]() {}, 24, 0, 0, TaskScheduler::RunMode::Once), std::invalid_argument); // When & Then - invalid minute REQUIRE_THROWS_AS( scheduler.schedule([]() {}, 0, -1, 0, TaskScheduler::RunMode::Once), std::invalid_argument); REQUIRE_THROWS_AS( scheduler.schedule([]() {}, 0, 60, 0, TaskScheduler::RunMode::Once), std::invalid_argument); // When & Then - invalid second REQUIRE_THROWS_AS( scheduler.schedule([]() {}, 0, 0, -1, TaskScheduler::RunMode::Once), std::invalid_argument); REQUIRE_THROWS_AS( scheduler.schedule([]() {}, 0, 0, 61, TaskScheduler::RunMode::Once), std::invalid_argument); } SECTION("when invalid mode combination is used then exception is thrown") { // Given - recreate blocker for this test auto testBlocker = std::make_unique(); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); // When & Then REQUIRE_THROWS_AS(scheduler.schedule([]() {}, 0, 0, 0, TaskScheduler::RunMode::Forever | TaskScheduler::RunMode::Once), std::invalid_argument); } SECTION("when multiple tasks are scheduled then all execute") { // Given auto threadHandle = std::make_unique(); std::function threadFn; bool task1Executed = false; bool task2Executed = false; // Set up thread handle expectations before moving it ALLOW_CALL(*threadHandle, join()); ALLOW_CALL(*threadHandle, joinable()).RETURN(true); // Recreate blocker for this test auto testBlocker = std::make_unique(); // Expect createThread to be called, save thread function REQUIRE_CALL(*threadMgr, createThread(_)) .LR_RETURN(std::move(threadHandle)) .LR_SIDE_EFFECT(threadFn = std::move(_1)); // Mock time provider calls ALLOW_CALL(*timeProvider, now()).LR_RETURN(test::TIMEPOINT_NOW); // Allow blocker calls ALLOW_CALL(*testBlocker, blockFor(_)); ALLOW_CALL(*testBlocker, notify()); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); auto taskFunction1 = [&]() { task1Executed = true; }; auto taskFunction2 = [&]() { task2Executed = true; scheduler.stop(); // stop after both tasks have had a chance to execute }; // When scheduler.schedule(taskFunction1, 0, 0, 0, TaskScheduler::RunMode::OnStart); scheduler.schedule(taskFunction2, 0, 0, 0, TaskScheduler::RunMode::OnStart); scheduler.start(); // Execute the thread function to simulate the scheduler thread threadFn(); // Then REQUIRE(task1Executed); REQUIRE(task2Executed); } SECTION("when task is scheduled with Forever mode then it repeats") { // Given auto threadHandle = std::make_unique(); std::function threadFn; int executionCount = 0; auto currentTime = test::TIMEPOINT_NOW; // Set up thread handle expectations before moving it ALLOW_CALL(*threadHandle, join()); ALLOW_CALL(*threadHandle, joinable()).RETURN(true); // Recreate blocker for this test auto testBlocker = std::make_unique(); // Expect createThread to be called, save thread function REQUIRE_CALL(*threadMgr, createThread(_)) .LR_RETURN(std::move(threadHandle)) .LR_SIDE_EFFECT(threadFn = std::move(_1)); // Mock time provider calls - simulate time advancing ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); // Allow blocker calls and simulate time passage ALLOW_CALL(*testBlocker, blockFor(_)).LR_SIDE_EFFECT(currentTime += _1); ALLOW_CALL(*testBlocker, notify()); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); auto taskFunction = [&]() { executionCount++; if (executionCount >= 2) { scheduler.stop(); // stop after 2 executions } }; // Schedule task to run at a specific time (not immediately) and repeat // forever This ensures the task doesn't get stuck in an infinite OnStart // loop scheduler.schedule(taskFunction, 12, 0, 1, TaskScheduler::RunMode::Forever); // When scheduler.start(); // Execute the thread function to simulate the scheduler thread threadFn(); // Then REQUIRE(executionCount >= 2); } SECTION("when task is scheduled with Forever and OnStart mode then it " "executes on start and at scheduled time only") { // Given auto threadHandle = std::make_unique(); std::function threadFn; std::vector executionTimes; auto currentTime = test::TIMEPOINT_NOW; // 2020-01-01 12:00:00 // Schedule task for 12:00:05 (5 seconds after current time) auto scheduledTimeDelta = std::chrono::seconds{5}; auto scheduledTime = currentTime + scheduledTimeDelta; // Set up thread handle expectations before moving it ALLOW_CALL(*threadHandle, join()); ALLOW_CALL(*threadHandle, joinable()).RETURN(true); // Recreate blocker for this test auto testBlocker = std::make_unique(); // Expect createThread to be called, save thread function REQUIRE_CALL(*threadMgr, createThread(_)) .LR_RETURN(std::move(threadHandle)) .LR_SIDE_EFFECT(threadFn = std::move(_1)); // Mock time provider calls - simulate time advancing ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); // Also add a timeout mechanism in case the scheduler doesn't execute as // expected auto timeoutTime = test::TIMEPOINT_NOW + std::chrono::minutes(2); // Allow blocker calls and simulate time passage ALLOW_CALL(*testBlocker, blockFor(_)) .LR_SIDE_EFFECT( // Advance time by the blocked amount currentTime += _1;); ALLOW_CALL(*testBlocker, notify()); TaskScheduler scheduler(logger, *timeProvider, *threadMgr, std::move(testBlocker)); auto taskFunction = [&]() { // Record the current time when this execution happens executionTimes.push_back(currentTime); // Stop after 2 executions (the expected behavior) if (executionTimes.size() >= 2) { scheduler.stop(); } }; // When - schedule task with both Forever and OnStart modes // Set time to 12:00:05 (5 seconds after our test TIMEPOINT_NOW) scheduler.schedule(taskFunction, 12, 0, 5, TaskScheduler::RunMode::Forever | TaskScheduler::RunMode::OnStart); scheduler.start(); // Execute the thread function to simulate the scheduler thread threadFn(); // Then - task should have executed exactly twice: // 1. Once immediately due to OnStart (at TIMEPOINT_NOW) // 2. Once at the scheduled time (12:00:05) // But NOT more than that (which would indicate the bug) // With the bug, executionTimes will have many entries due to infinite loop // Without the bug, we should have exactly 2 entries REQUIRE(executionTimes.size() == 2); // First execution should be at the initial time (OnStart) REQUIRE(executionTimes[0] == test::TIMEPOINT_NOW); // Second execution should be at or after the scheduled time REQUIRE(executionTimes[1] >= scheduledTime); // Verify that time has advanced appropriately // Current time should be at or after the scheduled time REQUIRE(currentTime >= scheduledTime); } }